Skip to main content

sr_core/
manifest.rs

1//! `sr-manifest.json` — the per-release completion record.
2//!
3//! sr uploads this file as the final asset on every release, **after** all
4//! other stages (including `post_release` hooks) have succeeded. Presence of
5//! the manifest on a tag's release is proof the pipeline finished; absence
6//! means either (a) sr never cut this release (legacy/manual tag) or (b) sr
7//! died mid-pipeline. sr can't distinguish those two remotely, so it warns
8//! rather than blocks.
9
10use std::collections::HashSet;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::ReleaseError;
15use crate::release::VcsProvider;
16
17/// File name of the manifest asset uploaded to every release.
18pub const MANIFEST_ASSET_NAME: &str = "sr-manifest.json";
19
20/// Completion record for a single release.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Manifest {
23    /// sr version that produced this release.
24    pub sr_version: String,
25    /// Tag name (e.g. "v1.2.3").
26    pub tag: String,
27    /// Full SHA of the commit the tag points to.
28    pub commit_sha: String,
29    /// Resolved asset basenames that were uploaded to the release.
30    /// Reconciliation compares this against the release's actual asset list
31    /// to detect partial uploads on re-runs.
32    pub artifacts: Vec<String>,
33    /// UTC RFC-3339 timestamp when the manifest was written.
34    pub completed_at: String,
35}
36
37/// Verdict produced by the reconciler against a tag's remote state.
38#[derive(Debug, Clone)]
39pub enum ReleaseStatus {
40    /// Manifest present — release is proven complete.
41    Complete(Manifest),
42    /// Manifest present but the declared artifacts don't match what's on the
43    /// release (e.g. the manifest lists `sr-linux.tar.gz` but the release has
44    /// no asset with that name). Indicates a partial re-upload.
45    Incomplete {
46        manifest: Manifest,
47        missing_artifacts: Vec<String>,
48    },
49    /// No manifest asset on this release. Could be legacy (predates sr-manifest)
50    /// or could be an sr release that died before the manifest was written.
51    /// Reconciler can't distinguish these; warns instead of blocking.
52    Unknown,
53}
54
55impl ReleaseStatus {
56    pub fn is_complete(&self) -> bool {
57        matches!(self, ReleaseStatus::Complete(_))
58    }
59}
60
61/// Inspect the release for `tag` and classify its completion status.
62///
63/// - Manifest present + all declared artifacts on the release → `Complete`.
64/// - Manifest present + some declared artifact missing → `Incomplete`.
65/// - Manifest absent → `Unknown` (legacy release or sr died before uploading).
66pub fn check_release_status<V: VcsProvider + ?Sized>(
67    vcs: &V,
68    tag: &str,
69) -> Result<ReleaseStatus, ReleaseError> {
70    let bytes = match vcs.fetch_asset(tag, MANIFEST_ASSET_NAME)? {
71        Some(b) => b,
72        None => return Ok(ReleaseStatus::Unknown),
73    };
74    let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|e| {
75        ReleaseError::Vcs(format!(
76            "failed to parse {MANIFEST_ASSET_NAME} on release {tag}: {e}"
77        ))
78    })?;
79
80    let assets: HashSet<String> = vcs.list_assets(tag)?.into_iter().collect();
81    let missing: Vec<String> = manifest
82        .artifacts
83        .iter()
84        .filter(|a| !assets.contains(a.as_str()))
85        .cloned()
86        .collect();
87
88    if missing.is_empty() {
89        Ok(ReleaseStatus::Complete(manifest))
90    } else {
91        Ok(ReleaseStatus::Incomplete {
92            manifest,
93            missing_artifacts: missing,
94        })
95    }
96}
97
98/// Produce a UTC RFC-3339 timestamp without pulling in `chrono`.
99pub(crate) fn utc_rfc3339_now() -> String {
100    let secs = std::time::SystemTime::now()
101        .duration_since(std::time::UNIX_EPOCH)
102        .unwrap_or_default()
103        .as_secs() as i64;
104
105    // civil_from_days (Howard Hinnant) — same as release::today_string.
106    let z = secs / 86400 + 719468;
107    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
108    let doe = (z - era * 146097) as u32;
109    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
110    let y = yoe as i64 + era * 400;
111    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
112    let mp = (5 * doy + 2) / 153;
113    let d = doy - (153 * mp + 2) / 5 + 1;
114    let m = if mp < 10 { mp + 3 } else { mp - 9 };
115    let y = if m <= 2 { y + 1 } else { y };
116
117    let sod = secs.rem_euclid(86400);
118    let h = sod / 3600;
119    let min = (sod % 3600) / 60;
120    let s = sod % 60;
121
122    format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z")
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn manifest_round_trip_json() {
131        let m = Manifest {
132            sr_version: "7.1.0".into(),
133            tag: "v1.2.3".into(),
134            commit_sha: "a".repeat(40),
135            artifacts: vec!["sr-linux.tar.gz".into(), "sr-macos.tar.gz".into()],
136            completed_at: "2026-04-18T12:34:56Z".into(),
137        };
138        let json = serde_json::to_string(&m).unwrap();
139        let back: Manifest = serde_json::from_str(&json).unwrap();
140        assert_eq!(back.tag, "v1.2.3");
141        assert_eq!(back.artifacts.len(), 2);
142    }
143
144    #[test]
145    fn release_status_complete_is_complete() {
146        let m = Manifest {
147            sr_version: "7.1.0".into(),
148            tag: "v1.0.0".into(),
149            commit_sha: "abc".into(),
150            artifacts: vec!["a".into()],
151            completed_at: "t".into(),
152        };
153        assert!(ReleaseStatus::Complete(m).is_complete());
154        assert!(!ReleaseStatus::Unknown.is_complete());
155    }
156
157    #[test]
158    fn utc_rfc3339_now_is_well_formed() {
159        let s = utc_rfc3339_now();
160        assert_eq!(s.len(), 20, "got {s}");
161        assert!(s.ends_with('Z'));
162        assert_eq!(&s[4..5], "-");
163        assert_eq!(&s[10..11], "T");
164    }
165}