Skip to main content

release_tool/
config.rs

1use anyhow::{Context, Result, bail};
2use semver::Version;
3use serde::Deserialize;
4use std::collections::{BTreeMap, BTreeSet, HashSet};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
9#[serde(deny_unknown_fields)]
10pub struct Config {
11    pub required_version: String,
12    pub repository: RepositoryConfig,
13    #[serde(default)]
14    pub hooks: HooksConfig,
15    #[serde(default)]
16    pub publishers: BTreeMap<String, PublisherConfig>,
17    #[serde(default)]
18    pub targets: Vec<TargetConfig>,
19}
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
22#[serde(deny_unknown_fields)]
23pub struct RepositoryConfig {
24    pub github: String,
25    pub branch: String,
26}
27
28#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
29#[serde(deny_unknown_fields)]
30pub struct HooksConfig {
31    #[serde(default)]
32    pub preflight: Vec<String>,
33}
34
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
36#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
37pub enum PublisherConfig {
38    GithubRelease {
39        title: String,
40        prerelease: bool,
41    },
42    GithubMaven {
43        settings: PathBuf,
44        server_id: String,
45    },
46}
47
48#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
49#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
50pub enum TargetConfig {
51    DockerArchive {
52        name: String,
53        publisher: String,
54        #[serde(default)]
55        default: bool,
56        #[serde(default)]
57        depends_on: Vec<String>,
58        platform: String,
59        image: String,
60        asset: String,
61        build: Vec<String>,
62        local_check: Vec<String>,
63    },
64    MavenReactor {
65        name: String,
66        publisher: String,
67        #[serde(default)]
68        default: bool,
69        #[serde(default)]
70        depends_on: Vec<String>,
71        wrapper: PathBuf,
72        pom: PathBuf,
73        projects: Vec<String>,
74        also_make: bool,
75        remote_check: Vec<String>,
76    },
77    OciImage {
78        name: String,
79        #[serde(default)]
80        default: bool,
81        #[serde(default)]
82        depends_on: Vec<String>,
83        image: String,
84        platform: String,
85        reuse_check: Vec<String>,
86        build: Vec<String>,
87    },
88}
89
90impl TargetConfig {
91    pub fn name(&self) -> &str {
92        match self {
93            Self::DockerArchive { name, .. }
94            | Self::MavenReactor { name, .. }
95            | Self::OciImage { name, .. } => name,
96        }
97    }
98
99    pub fn publisher(&self) -> &str {
100        match self {
101            Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
102                publisher
103            }
104            Self::OciImage { .. } => crate::oci::OCI_REGISTRY_PUBLISHER,
105        }
106    }
107
108    pub fn configured_publisher(&self) -> Option<&str> {
109        match self {
110            Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
111                Some(publisher)
112            }
113            Self::OciImage { .. } => None,
114        }
115    }
116
117    pub fn is_default(&self) -> bool {
118        match self {
119            Self::DockerArchive { default, .. }
120            | Self::MavenReactor { default, .. }
121            | Self::OciImage { default, .. } => *default,
122        }
123    }
124
125    pub fn dependencies(&self) -> &[String] {
126        match self {
127            Self::DockerArchive { depends_on, .. }
128            | Self::MavenReactor { depends_on, .. }
129            | Self::OciImage { depends_on, .. } => depends_on,
130        }
131    }
132
133    pub fn is_oci_image(&self) -> bool {
134        matches!(self, Self::OciImage { .. })
135    }
136}
137
138impl Config {
139    pub fn load(path: &Path) -> Result<Self> {
140        let source = fs::read_to_string(path)
141            .with_context(|| format!("failed to read {}", path.display()))?;
142        Self::parse(&source).with_context(|| format!("invalid {}", path.display()))
143    }
144
145    pub fn parse(source: &str) -> Result<Self> {
146        let document: toml::Value = toml::from_str(source).context("invalid TOML")?;
147        let required = document
148            .get("required_version")
149            .and_then(toml::Value::as_str)
150            .context("`required_version` must be a semantic version string")?;
151        let required = Version::parse(required)
152            .with_context(|| format!("invalid required_version `{required}`"))?;
153        let current = Version::parse(env!("CARGO_PKG_VERSION"))
154            .expect("Cargo package version must be valid semantic versioning");
155        if current < required {
156            bail!(
157                "release.toml requires release-tool >= {required}; current version is {current}\n\
158                 update the repository's release-tool dependency and Cargo.lock"
159            );
160        }
161
162        let config: Self = toml::from_str(source).context("config shape is invalid")?;
163        config.validate()?;
164        Ok(config)
165    }
166
167    pub(crate) fn targets_in_dependency_order(&self) -> Result<Vec<&TargetConfig>> {
168        let by_name = self
169            .targets
170            .iter()
171            .map(|target| (target.name(), target))
172            .collect::<BTreeMap<_, _>>();
173        let mut indegree = by_name
174            .keys()
175            .map(|name| (*name, by_name[name].dependencies().len()))
176            .collect::<BTreeMap<_, _>>();
177        let mut downstream = BTreeMap::<&str, Vec<&str>>::new();
178        for target in &self.targets {
179            for dependency in target.dependencies() {
180                downstream
181                    .entry(dependency)
182                    .or_default()
183                    .push(target.name());
184            }
185        }
186        for targets in downstream.values_mut() {
187            targets.sort_unstable();
188        }
189        let mut ready = indegree
190            .iter()
191            .filter_map(|(name, count)| (*count == 0).then_some(*name))
192            .collect::<BTreeSet<_>>();
193        let mut ordered = Vec::with_capacity(self.targets.len());
194        while let Some(name) = ready.pop_first() {
195            ordered.push(by_name[name]);
196            for dependent in downstream.get(name).into_iter().flatten() {
197                let count = indegree
198                    .get_mut(dependent)
199                    .expect("validated dependent target must have indegree");
200                *count -= 1;
201                if *count == 0 {
202                    ready.insert(dependent);
203                }
204            }
205        }
206        if ordered.len() != self.targets.len() {
207            let cycle = indegree
208                .into_iter()
209                .filter_map(|(name, count)| (count > 0).then_some(name))
210                .collect::<Vec<_>>()
211                .join(", ");
212            bail!("target dependency cycle includes: {cycle}");
213        }
214        Ok(ordered)
215    }
216
217    fn validate(&self) -> Result<()> {
218        if self.repository.github.split('/').count() != 2
219            || self.repository.github.starts_with('/')
220            || self.repository.github.ends_with('/')
221        {
222            bail!(
223                "repository.github must use `owner/name`: {}",
224                self.repository.github
225            );
226        }
227        if self.repository.branch.trim().is_empty() {
228            bail!("repository.branch must not be empty");
229        }
230        validate_command("hooks.preflight", &self.hooks.preflight, true)?;
231
232        let mut names = HashSet::new();
233        for target in &self.targets {
234            if !names.insert(target.name()) {
235                bail!("duplicate target name `{}`", target.name());
236            }
237        }
238        self.validate_dependencies()?;
239        self.targets_in_dependency_order()?;
240
241        for target in &self.targets {
242            if let Some(publisher_name) = target.configured_publisher() {
243                let publisher = self.publishers.get(publisher_name).with_context(|| {
244                    format!(
245                        "target `{}` references unknown publisher `{publisher_name}`",
246                        target.name()
247                    )
248                })?;
249                let compatible = matches!(
250                    (target, publisher),
251                    (
252                        TargetConfig::DockerArchive { .. },
253                        PublisherConfig::GithubRelease { .. }
254                    ) | (
255                        TargetConfig::MavenReactor { .. },
256                        PublisherConfig::GithubMaven { .. }
257                    )
258                );
259                if !compatible {
260                    bail!(
261                        "target `{}` is incompatible with publisher `{publisher_name}`",
262                        target.name()
263                    );
264                }
265            }
266            match target {
267                TargetConfig::DockerArchive {
268                    build,
269                    local_check,
270                    asset,
271                    ..
272                } => {
273                    validate_command(&format!("targets.{}.build", target.name()), build, false)?;
274                    validate_command(
275                        &format!("targets.{}.local_check", target.name()),
276                        local_check,
277                        false,
278                    )?;
279                    validate_asset_name(target.name(), asset)?;
280                }
281                TargetConfig::MavenReactor {
282                    projects,
283                    remote_check,
284                    ..
285                } => {
286                    if projects.is_empty() {
287                        bail!("target `{}` must select Maven projects", target.name());
288                    }
289                    validate_command(
290                        &format!("targets.{}.remote_check", target.name()),
291                        remote_check,
292                        true,
293                    )?;
294                }
295                TargetConfig::OciImage {
296                    image,
297                    platform,
298                    reuse_check,
299                    build,
300                    ..
301                } => {
302                    crate::oci::validate_image_template(image, platform)?;
303                    validate_command(
304                        &format!("targets.{}.reuse_check", target.name()),
305                        reuse_check,
306                        false,
307                    )?;
308                    validate_command(&format!("targets.{}.build", target.name()), build, false)?;
309                }
310            }
311        }
312        Ok(())
313    }
314
315    fn validate_dependencies(&self) -> Result<()> {
316        let by_name = self
317            .targets
318            .iter()
319            .map(|target| (target.name(), target))
320            .collect::<BTreeMap<_, _>>();
321        for target in &self.targets {
322            let mut unique = HashSet::new();
323            for dependency in target.dependencies() {
324                if dependency == target.name() {
325                    bail!("target `{}` cannot depend on itself", target.name());
326                }
327                if !unique.insert(dependency) {
328                    bail!(
329                        "target `{}` contains duplicate dependency `{dependency}`",
330                        target.name()
331                    );
332                }
333                let dependency_target = by_name.get(dependency.as_str()).with_context(|| {
334                    format!(
335                        "target `{}` references unknown dependency `{dependency}`",
336                        target.name()
337                    )
338                })?;
339                if !target.is_oci_image() || !dependency_target.is_oci_image() {
340                    bail!(
341                        "target `{}` cannot consume dependency `{dependency}`; v1 only supports oci_image -> oci_image dependencies",
342                        target.name()
343                    );
344                }
345            }
346        }
347        Ok(())
348    }
349}
350
351fn validate_asset_name(target: &str, asset: &str) -> Result<()> {
352    let asset_path = Path::new(asset);
353    if asset_path.file_name().and_then(|name| name.to_str()) != Some(asset)
354        || asset == "."
355        || asset == ".."
356    {
357        bail!("target `{target}` asset must be one file name");
358    }
359    Ok(())
360}
361
362fn validate_command(name: &str, command: &[String], allow_empty: bool) -> Result<()> {
363    if command.is_empty() {
364        if allow_empty {
365            return Ok(());
366        }
367        bail!("{name} must not be empty");
368    }
369    if command.iter().any(|argument| argument.is_empty()) {
370        bail!("{name} arguments must not be empty");
371    }
372    Ok(())
373}