shipshape_core/release/
distribution.rs1use crate::contract::schema::{Adapter, Registry, Target};
9use crate::protocol::facts::DistributionSurface;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum UndeclaredDistribution {
14 GhReleases {
17 evidence: Vec<String>,
19 },
20 Homebrew,
22}
23
24#[must_use]
26pub fn find_undeclared_distribution(
27 targets: &[Target],
28 surface: &DistributionSurface,
29 has_unserved_homebrew_tap: bool,
30) -> Vec<UndeclaredDistribution> {
31 let has_gh_releases = targets
32 .iter()
33 .any(|target| target.registry == Registry::GhReleases);
34 let has_homebrew = targets
35 .iter()
36 .any(|target| target.registry == Registry::Homebrew);
37 let mut findings = Vec::new();
38
39 if (surface.has_cargo_dist || !surface.tag_triggered_workflows.is_empty()) && !has_gh_releases {
40 let mut evidence = surface.cargo_dist_evidence.clone();
41 evidence.extend(
42 surface
43 .tag_triggered_workflows
44 .iter()
45 .map(|name| format!(".github/workflows/{name} (tag-triggered push workflow)")),
46 );
47 findings.push(UndeclaredDistribution::GhReleases { evidence });
48 }
49 if has_unserved_homebrew_tap && !has_homebrew {
50 findings.push(UndeclaredDistribution::Homebrew);
51 }
52 findings
53}
54
55#[must_use]
61pub fn delegated_publish_workflow_warnings(
62 targets: &[Target],
63 surface: &DistributionSurface,
64) -> Vec<String> {
65 let delegated_targets = targets
66 .iter()
67 .filter(|target| {
68 target.adapter == Adapter::CargoPublishCi && target.registry == Registry::CratesIo
69 })
70 .collect::<Vec<_>>();
71 if delegated_targets.is_empty() || !surface.tag_triggered_cargo_publish_workflows.is_empty() {
72 return Vec::new();
73 }
74
75 let mut delegated_packages = delegated_targets
76 .iter()
77 .filter_map(|target| target.package.as_deref())
78 .collect::<Vec<_>>();
79 delegated_packages.sort_unstable();
80 delegated_packages.dedup();
81 let subject = if delegated_packages.is_empty() {
82 "an unresolved rust package".to_string()
83 } else {
84 delegated_packages.join(", ")
85 };
86 let trigger_context = if surface.tag_triggered_workflows.is_empty() {
87 "no tag-triggered workflows were detected".to_string()
88 } else {
89 format!(
90 "no directly inspectable Cargo publish path was found in the detected tag-triggered workflows ({})",
91 surface.tag_triggered_workflows.join(", ")
92 )
93 };
94 vec![format!(
95 "cargo-publish-ci delegates crates.io publication for {subject} to CI, but no tag-triggered Cargo publish workflow was detected under .github/workflows; {trigger_context}. Add an `on: push: tags:` workflow that runs `cargo publish` directly or calls a repository-local reusable publish workflow, then re-plan"
96 )]
97}
98
99#[must_use]
101pub fn undeclared_distribution_warnings(findings: &[UndeclaredDistribution]) -> Vec<String> {
102 findings
103 .iter()
104 .map(|finding| match finding {
105 UndeclaredDistribution::GhReleases { evidence } => format!(
106 "{} detected, but the contract has no 'gh-releases' target — the tag phase would create the GitHub Release itself and collide with the repo's cargo-dist workflow, dropping its binaries and Homebrew publish. Add a target with registry: gh-releases, adapter: cargo-dist and re-plan",
107 evidence.join(", ")
108 ),
109 UndeclaredDistribution::Homebrew => "distribution.homebrew_tap is set, but the contract has no 'homebrew' target — the tap leg would be silently skipped. Add a target with registry: homebrew and the formula owner's adapter (`homebrew-tap` for the engine or `cargo-dist` for CI), then re-plan".to_string(),
110 })
111 .collect()
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::contract::schema::{Adapter, Ecosystem};
118
119 fn target(registry: Registry) -> Target {
120 Target {
121 ecosystem: Ecosystem::Rust,
122 package: Some("demo".to_string()),
123 registry,
124 adapter: Adapter::CargoDist,
125 }
126 }
127
128 #[test]
129 fn finds_both_undeclared_surfaces() {
130 let surface = DistributionSurface {
131 has_cargo_dist: true,
132 cargo_dist_evidence: vec!["dist-workspace.toml".to_string()],
133 tag_triggered_workflows: vec!["release.yml".to_string()],
134 tag_triggered_cargo_publish_workflows: vec![],
135 };
136 let findings = find_undeclared_distribution(&[], &surface, true);
137 assert_eq!(findings.len(), 2);
138 let warnings = undeclared_distribution_warnings(&findings);
139 assert!(warnings[0].contains("dist-workspace.toml"));
140 assert!(warnings[0].contains("release.yml"));
141 assert!(warnings[1].contains("homebrew_tap"));
142 }
143
144 #[test]
145 fn fully_declared_surface_is_green() {
146 let surface = DistributionSurface {
147 has_cargo_dist: true,
148 cargo_dist_evidence: vec!["Cargo.toml ([workspace.metadata.dist])".to_string()],
149 tag_triggered_workflows: vec!["release.yml".to_string()],
150 tag_triggered_cargo_publish_workflows: vec![],
151 };
152 let targets = vec![target(Registry::GhReleases), target(Registry::Homebrew)];
153 assert!(find_undeclared_distribution(&targets, &surface, true).is_empty());
154 }
155
156 #[test]
157 fn delegated_cargo_publish_without_a_workflow_warns_by_package_and_location() {
158 let surface = DistributionSurface {
159 has_cargo_dist: false,
160 cargo_dist_evidence: vec![],
161 tag_triggered_workflows: vec!["release.yml".to_string()],
162 tag_triggered_cargo_publish_workflows: vec![],
163 };
164 let mut delegated = target(Registry::CratesIo);
165 delegated.adapter = Adapter::CargoPublishCi;
166
167 let warnings = delegated_publish_workflow_warnings(&[delegated], &surface);
168 assert_eq!(warnings.len(), 1);
169 assert!(warnings[0].contains("demo"));
170 assert!(warnings[0].contains(".github/workflows"));
171 assert!(warnings[0].contains("release.yml"));
172 }
173
174 #[test]
175 fn detected_delegated_cargo_publish_workflow_is_green() {
176 let surface = DistributionSurface {
177 has_cargo_dist: false,
178 cargo_dist_evidence: vec![],
179 tag_triggered_workflows: vec!["publish.yml".to_string()],
180 tag_triggered_cargo_publish_workflows: vec!["publish.yml".to_string()],
181 };
182 let mut delegated = target(Registry::CratesIo);
183 delegated.adapter = Adapter::CargoPublishCi;
184 assert!(delegated_publish_workflow_warnings(&[delegated], &surface).is_empty());
185 }
186
187 #[test]
188 fn ci_delegated_homebrew_target_declares_the_tap_surface() {
189 let surface = DistributionSurface {
190 has_cargo_dist: true,
191 cargo_dist_evidence: vec!["dist-workspace.toml".to_string()],
192 tag_triggered_workflows: vec!["release.yml".to_string()],
193 tag_triggered_cargo_publish_workflows: vec![],
194 };
195 let mut homebrew = target(Registry::Homebrew);
196 homebrew.adapter = Adapter::CargoDist;
197 assert!(find_undeclared_distribution(
198 &[target(Registry::GhReleases), homebrew],
199 &surface,
200 true
201 )
202 .is_empty());
203 }
204}