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        #[serde(default)]
77        attachments: Vec<MavenAttachment>,
78    },
79    OciImage {
80        name: String,
81        #[serde(default)]
82        default: bool,
83        #[serde(default)]
84        depends_on: Vec<String>,
85        image: String,
86        platform: String,
87        reuse_check: Vec<String>,
88        build: Vec<String>,
89    },
90}
91
92#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
93#[serde(deny_unknown_fields)]
94pub struct MavenAttachment {
95    pub project: String,
96    pub classifier: String,
97    pub extension: String,
98}
99
100impl TargetConfig {
101    pub fn name(&self) -> &str {
102        match self {
103            Self::DockerArchive { name, .. }
104            | Self::MavenReactor { name, .. }
105            | Self::OciImage { name, .. } => name,
106        }
107    }
108
109    pub fn publisher(&self) -> &str {
110        match self {
111            Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
112                publisher
113            }
114            Self::OciImage { .. } => crate::oci::OCI_REGISTRY_PUBLISHER,
115        }
116    }
117
118    pub fn configured_publisher(&self) -> Option<&str> {
119        match self {
120            Self::DockerArchive { publisher, .. } | Self::MavenReactor { publisher, .. } => {
121                Some(publisher)
122            }
123            Self::OciImage { .. } => None,
124        }
125    }
126
127    pub fn is_default(&self) -> bool {
128        match self {
129            Self::DockerArchive { default, .. }
130            | Self::MavenReactor { default, .. }
131            | Self::OciImage { default, .. } => *default,
132        }
133    }
134
135    pub fn dependencies(&self) -> &[String] {
136        match self {
137            Self::DockerArchive { depends_on, .. }
138            | Self::MavenReactor { depends_on, .. }
139            | Self::OciImage { depends_on, .. } => depends_on,
140        }
141    }
142
143    pub fn is_oci_image(&self) -> bool {
144        matches!(self, Self::OciImage { .. })
145    }
146}
147
148impl Config {
149    pub fn load(path: &Path) -> Result<Self> {
150        let source = fs::read_to_string(path)
151            .with_context(|| format!("failed to read {}", path.display()))?;
152        Self::parse(&source).with_context(|| format!("invalid {}", path.display()))
153    }
154
155    pub fn parse(source: &str) -> Result<Self> {
156        let document: toml::Value = toml::from_str(source).context("invalid TOML")?;
157        let required = document
158            .get("required_version")
159            .and_then(toml::Value::as_str)
160            .context("`required_version` must be a semantic version string")?;
161        let required = Version::parse(required)
162            .with_context(|| format!("invalid required_version `{required}`"))?;
163        let current = Version::parse(env!("CARGO_PKG_VERSION"))
164            .expect("Cargo package version must be valid semantic versioning");
165        if current < required {
166            bail!(
167                "release.toml requires release-tool >= {required}; current version is {current}\n\
168                 update the repository's release-tool dependency and Cargo.lock"
169            );
170        }
171
172        let config: Self = toml::from_str(source).context("config shape is invalid")?;
173        config.validate()?;
174        Ok(config)
175    }
176
177    pub(crate) fn targets_in_dependency_order(&self) -> Result<Vec<&TargetConfig>> {
178        let by_name = self
179            .targets
180            .iter()
181            .map(|target| (target.name(), target))
182            .collect::<BTreeMap<_, _>>();
183        let mut indegree = by_name
184            .keys()
185            .map(|name| (*name, by_name[name].dependencies().len()))
186            .collect::<BTreeMap<_, _>>();
187        let mut downstream = BTreeMap::<&str, Vec<&str>>::new();
188        for target in &self.targets {
189            for dependency in target.dependencies() {
190                downstream
191                    .entry(dependency)
192                    .or_default()
193                    .push(target.name());
194            }
195        }
196        for targets in downstream.values_mut() {
197            targets.sort_unstable();
198        }
199        let mut ready = indegree
200            .iter()
201            .filter_map(|(name, count)| (*count == 0).then_some(*name))
202            .collect::<BTreeSet<_>>();
203        let mut ordered = Vec::with_capacity(self.targets.len());
204        while let Some(name) = ready.pop_first() {
205            ordered.push(by_name[name]);
206            for dependent in downstream.get(name).into_iter().flatten() {
207                let count = indegree
208                    .get_mut(dependent)
209                    .expect("validated dependent target must have indegree");
210                *count -= 1;
211                if *count == 0 {
212                    ready.insert(dependent);
213                }
214            }
215        }
216        if ordered.len() != self.targets.len() {
217            let cycle = indegree
218                .into_iter()
219                .filter_map(|(name, count)| (count > 0).then_some(name))
220                .collect::<Vec<_>>()
221                .join(", ");
222            bail!("target dependency cycle includes: {cycle}");
223        }
224        Ok(ordered)
225    }
226
227    fn validate(&self) -> Result<()> {
228        if self.repository.github.split('/').count() != 2
229            || self.repository.github.starts_with('/')
230            || self.repository.github.ends_with('/')
231        {
232            bail!(
233                "repository.github must use `owner/name`: {}",
234                self.repository.github
235            );
236        }
237        if self.repository.branch.trim().is_empty() {
238            bail!("repository.branch must not be empty");
239        }
240        validate_command("hooks.preflight", &self.hooks.preflight, true)?;
241
242        let mut names = HashSet::new();
243        for target in &self.targets {
244            if !names.insert(target.name()) {
245                bail!("duplicate target name `{}`", target.name());
246            }
247        }
248        self.validate_dependencies()?;
249        self.targets_in_dependency_order()?;
250
251        for target in &self.targets {
252            if let Some(publisher_name) = target.configured_publisher() {
253                let publisher = self.publishers.get(publisher_name).with_context(|| {
254                    format!(
255                        "target `{}` references unknown publisher `{publisher_name}`",
256                        target.name()
257                    )
258                })?;
259                let compatible = matches!(
260                    (target, publisher),
261                    (
262                        TargetConfig::DockerArchive { .. },
263                        PublisherConfig::GithubRelease { .. }
264                    ) | (
265                        TargetConfig::MavenReactor { .. },
266                        PublisherConfig::GithubMaven { .. }
267                    )
268                );
269                if !compatible {
270                    bail!(
271                        "target `{}` is incompatible with publisher `{publisher_name}`",
272                        target.name()
273                    );
274                }
275            }
276            match target {
277                TargetConfig::DockerArchive {
278                    build,
279                    local_check,
280                    asset,
281                    ..
282                } => {
283                    validate_command(&format!("targets.{}.build", target.name()), build, false)?;
284                    validate_command(
285                        &format!("targets.{}.local_check", target.name()),
286                        local_check,
287                        false,
288                    )?;
289                    validate_asset_name(target.name(), asset)?;
290                }
291                TargetConfig::MavenReactor {
292                    projects,
293                    remote_check,
294                    attachments,
295                    ..
296                } => {
297                    if projects.is_empty() {
298                        bail!("target `{}` must select Maven projects", target.name());
299                    }
300                    let mut identities = HashSet::new();
301                    for attachment in attachments {
302                        crate::domain::validate_maven_attachment(
303                            &attachment.classifier,
304                            &attachment.extension,
305                        )?;
306                        if attachment.project.is_empty()
307                            || Path::new(&attachment.project)
308                                .components()
309                                .any(|part| !matches!(part, std::path::Component::Normal(_)))
310                        {
311                            bail!("Maven attachment project must be a relative module path");
312                        }
313                        if !identities.insert((
314                            &attachment.project,
315                            &attachment.classifier,
316                            &attachment.extension,
317                        )) {
318                            bail!(
319                                "duplicate Maven attachment for project `{}`",
320                                attachment.project
321                            );
322                        }
323                    }
324                    validate_command(
325                        &format!("targets.{}.remote_check", target.name()),
326                        remote_check,
327                        true,
328                    )?;
329                }
330                TargetConfig::OciImage {
331                    image,
332                    platform,
333                    reuse_check,
334                    build,
335                    ..
336                } => {
337                    crate::oci::validate_image_template(image, platform)?;
338                    validate_command(
339                        &format!("targets.{}.reuse_check", target.name()),
340                        reuse_check,
341                        false,
342                    )?;
343                    validate_command(&format!("targets.{}.build", target.name()), build, false)?;
344                }
345            }
346        }
347        Ok(())
348    }
349
350    fn validate_dependencies(&self) -> Result<()> {
351        let by_name = self
352            .targets
353            .iter()
354            .map(|target| (target.name(), target))
355            .collect::<BTreeMap<_, _>>();
356        for target in &self.targets {
357            let mut unique = HashSet::new();
358            for dependency in target.dependencies() {
359                if dependency == target.name() {
360                    bail!("target `{}` cannot depend on itself", target.name());
361                }
362                if !unique.insert(dependency) {
363                    bail!(
364                        "target `{}` contains duplicate dependency `{dependency}`",
365                        target.name()
366                    );
367                }
368                let dependency_target = by_name.get(dependency.as_str()).with_context(|| {
369                    format!(
370                        "target `{}` references unknown dependency `{dependency}`",
371                        target.name()
372                    )
373                })?;
374                if !target.is_oci_image() || !dependency_target.is_oci_image() {
375                    bail!(
376                        "target `{}` cannot consume dependency `{dependency}`; v1 only supports oci_image -> oci_image dependencies",
377                        target.name()
378                    );
379                }
380            }
381        }
382        Ok(())
383    }
384}
385
386fn validate_asset_name(target: &str, asset: &str) -> Result<()> {
387    let asset_path = Path::new(asset);
388    if asset_path.file_name().and_then(|name| name.to_str()) != Some(asset)
389        || asset == "."
390        || asset == ".."
391    {
392        bail!("target `{target}` asset must be one file name");
393    }
394    Ok(())
395}
396
397fn validate_command(name: &str, command: &[String], allow_empty: bool) -> Result<()> {
398    if command.is_empty() {
399        if allow_empty {
400            return Ok(());
401        }
402        bail!("{name} must not be empty");
403    }
404    if command.iter().any(|argument| argument.is_empty()) {
405        bail!("{name} arguments must not be empty");
406    }
407    Ok(())
408}