Skip to main content

shipshape_core/release/
distribution.rs

1//! Facts-to-contract checks for binary-distribution release surfaces.
2//!
3//! A release contract is authoritative, but a repository can still contain
4//! distribution infrastructure it forgot to declare. These checks make that
5//! mismatch visible during validation and refuse an irreversible cut before the
6//! tag phase can collide with cargo-dist.
7
8use crate::contract::schema::{Adapter, Registry, Target};
9use crate::protocol::facts::DistributionSurface;
10
11/// One under-declared distribution surface found by [`find_undeclared_distribution`].
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum UndeclaredDistribution {
14    /// cargo-dist configuration or a tag-triggered workflow lacks its delegated
15    /// GitHub Release target.
16    GhReleases {
17        /// Configuration files and workflows establishing the delegated release.
18        evidence: Vec<String>,
19    },
20    /// A declared tap has no Homebrew target.
21    Homebrew,
22}
23
24/// Find distribution infrastructure that the contract's targets omit.
25#[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/// Warn when crates.io publication is delegated to CI but no credible workflow
56/// was detected to perform it after the coordinator pushes the release tag.
57///
58/// This deliberately remains advisory. Workflow execution can hide behind shell
59/// scripts or remote reusable workflows that static repository facts cannot prove.
60#[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/// Render findings as validation/plan warnings.
100#[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}