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}