Skip to main content

release_kit/release_engine/
publish.rs

1//! `rk release publish`: put the built and attested release on the forge,
2//! and create the tag last.
3//!
4//! It reads the build report, checks the tag again, because a rerun can
5//! start later than its build, and converges with what the forge already
6//! holds: it skips what exists with the same bytes, creates what is
7//! missing, and refuses what exists with other bytes, except on a GitHub
8//! draft, which nothing public reads and which it repairs.
9
10use camino::{Utf8Path, Utf8PathBuf};
11use serde::Serialize;
12
13use crate::detect::Forge;
14use crate::diagnostic::{Diagnostic, Reason};
15use crate::error::RkError;
16
17use super::adapter::Version;
18use super::build::{BuildReport, forge_outcome};
19use super::forge::{Asset, Client};
20use super::guard::Outcome;
21use super::{BUILD_REPORT, CHECKSUMS, Context, Done, OUT_DIR};
22
23/// The report's shape version.
24pub const SCHEMA: &str = "rk.release-publish/1";
25
26/// The machine form of `rk release publish`.
27#[derive(Debug, Default, Serialize)]
28pub struct PublishReport {
29    /// The shape version of this document.
30    pub schema: &'static str,
31    /// `published`, `resumed`, or `skip`.
32    pub state: &'static str,
33    /// Why nothing was published, where nothing was.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub reason: Option<String>,
36    /// The tag the release carries.
37    pub tag: String,
38    /// Whether the release is a candidate.
39    pub candidate: bool,
40    /// Each file uploaded where none was.
41    pub uploaded: Vec<String>,
42    /// Each draft asset replaced because its bytes differed.
43    pub replaced: Vec<String>,
44    /// Each file already present with the same bytes.
45    pub kept: Vec<String>,
46    /// Each link added to a GitLab release.
47    pub linked: Vec<String>,
48}
49
50/// One local file the release carries, with its SHA-256.
51struct Local {
52    path: Utf8PathBuf,
53    name: String,
54    sha256: String,
55}
56
57/// Publish the release the build report describes.
58///
59/// # Errors
60///
61/// A refusal of a missing or foreign build report, of a conflicting tag,
62/// or of an asset whose bytes differ from what shipped; a forge failure.
63pub fn run(target: &Utf8Path) -> Result<Done<PublishReport>, RkError> {
64    let context = Context::load(target)?;
65    context.require_tools("publish")?;
66    let build = read_build(target)?;
67    let mut report = PublishReport {
68        schema: SCHEMA,
69        state: "skip",
70        tag: build.tag.clone(),
71        candidate: build.candidate,
72        ..PublishReport::default()
73    };
74    if build.state == "skip" {
75        report.reason.clone_from(&build.reason);
76        return Ok(Done {
77            report,
78            lines: vec![format!(
79                "skip: {}",
80                build
81                    .reason
82                    .as_deref()
83                    .unwrap_or("the build released nothing")
84            )],
85            next: Vec::new(),
86        });
87    }
88    let head = context.head()?;
89    if head != build.commit {
90        return Err(RkError::refusal(
91            Diagnostic::new(
92                Reason::StateDrift,
93                format!(
94                    "the build report is of {}, and the checkout stands at {head}",
95                    build.commit
96                ),
97            )
98            .expected("the build report of this commit")
99            .target_state("unchanged"),
100        ));
101    }
102    let client = Client::resolve(context.forge, target, &context.repo)?;
103    // SATISFIES release-engine:a-version-releases-once
104    match forge_outcome(&client, &head, &build.tag)? {
105        Outcome::Skip(reason) => {
106            report.reason = Some(reason.clone());
107            return Ok(Done {
108                report,
109                lines: vec![format!("skip: {reason}")],
110                next: Vec::new(),
111            });
112        }
113        Outcome::Refuse(reason) => return Err(super::conflict(reason, super::RECOVERY_TAG)),
114        Outcome::Release | Outcome::Resume => {}
115    }
116    let locals = locals(target, &build)?;
117    let notes = build
118        .notes
119        .as_deref()
120        .and_then(|notes| std::fs::read_to_string(target.join(notes)).ok())
121        .unwrap_or_default();
122    let resumed = match context.forge {
123        Forge::Github => github(&client, target, &head, &build, &notes, &locals, &mut report)?,
124        Forge::Gitlab => gitlab(&client, target, &head, &build, &notes, &locals, &mut report)?,
125    };
126    report.state = if resumed { "resumed" } else { "published" };
127    let mut lines = vec![format!(
128        "{} {}{} at {head}",
129        report.state,
130        build.tag,
131        if build.candidate {
132            " as a prerelease"
133        } else {
134            ""
135        }
136    )];
137    for (verb, names) in [
138        ("uploaded", &report.uploaded),
139        ("replaced", &report.replaced),
140        ("kept", &report.kept),
141        ("linked", &report.linked),
142    ] {
143        if !names.is_empty() {
144            lines.push(format!("{verb} {}", names.join(", ")));
145        }
146    }
147    Ok(Done {
148        report,
149        lines,
150        next: vec!["rk release status --wait <sha> reports the release".to_owned()],
151    })
152}
153
154/// The build report `rk release build` wrote for this checkout.
155fn read_build(target: &Utf8Path) -> Result<BuildReport, RkError> {
156    let report_path = target.join(OUT_DIR).join(BUILD_REPORT);
157    std::fs::read_to_string(&report_path)
158        .ok()
159        .and_then(|text| serde_json::from_str(&text).ok())
160        .filter(|report: &BuildReport| report.schema == super::build::SCHEMA)
161        .ok_or_else(|| {
162            RkError::refusal(
163                Diagnostic::new(
164                    Reason::StateDrift,
165                    format!("no {} report at {report_path}", super::build::SCHEMA),
166                )
167                .expected("the report rk release build writes, carried to this job")
168                .action("run rk release build first, and carry .rk-release/ to this job")
169                .target_state("unchanged"),
170            )
171        })
172}
173
174/// Every file the release carries: each artifact, unchanged since the
175/// build, the checksum file, and each provenance bundle beside an
176/// artifact.
177fn locals(target: &Utf8Path, build: &BuildReport) -> Result<Vec<Local>, RkError> {
178    let mut locals = Vec::new();
179    for artifact in &build.artifacts {
180        let path = Utf8PathBuf::from(&artifact.path);
181        let sha256 = super::file_sha256(&target.join(&path))?;
182        if sha256 != artifact.sha256 {
183            return Err(RkError::refusal(
184                Diagnostic::new(
185                    Reason::StateDrift,
186                    format!("{} changed since the build reported it", artifact.path),
187                )
188                .expected("the artifacts the build reported, carried unchanged")
189                .target_state("unchanged"),
190            ));
191        }
192        locals.push(Local {
193            name: super::file_name(&path),
194            path,
195            sha256,
196        });
197    }
198    let mut extra: Vec<Utf8PathBuf> = vec![Utf8PathBuf::from(format!("{OUT_DIR}/{CHECKSUMS}"))];
199    extra.extend(
200        build
201            .artifacts
202            .iter()
203            .map(|artifact| Utf8PathBuf::from(format!("{}{BUNDLE}", artifact.path))),
204    );
205    for path in extra {
206        if target.join(&path).is_file() {
207            locals.push(Local {
208                name: super::file_name(&path),
209                sha256: super::file_sha256(&target.join(&path))?,
210                path,
211            });
212        }
213    }
214    Ok(locals)
215}
216
217/// The suffix of the provenance bundle the attest step writes beside an
218/// artifact.
219const BUNDLE: &str = ".sigstore.json";
220
221/// Sort the local files against what the forge holds: the missing, the
222/// differing, and the same.
223///
224/// Where `keep_bundles` holds, a held provenance bundle counts as the same
225/// when the artifact beside it is held with this build's bytes: each
226/// attestation run signs new bundle bytes for the same artifact, so a rerun
227/// keeps the bundle that shipped. The publish verifies no signature.
228fn sort<'a>(
229    locals: &'a [Local],
230    held: &[Asset],
231    keep_bundles: bool,
232) -> (Vec<&'a Local>, Vec<&'a Local>, Vec<&'a Local>) {
233    let (mut missing, mut differing, mut same) = (Vec::new(), Vec::new(), Vec::new());
234    for local in locals {
235        match held.iter().rev().find(|asset| asset.name == local.name) {
236            None => missing.push(local),
237            Some(asset) if asset.sha256.as_deref() == Some(local.sha256.as_str()) => {
238                same.push(local);
239            }
240            Some(_) => differing.push(local),
241        }
242    }
243    if keep_bundles {
244        // SATISFIES release-engine:every-step-converges
245        let (kept, rest): (Vec<&Local>, Vec<&Local>) = differing.into_iter().partition(|local| {
246            local
247                .name
248                .strip_suffix(BUNDLE)
249                .is_some_and(|artifact| same.iter().any(|held: &&Local| held.name == artifact))
250        });
251        same.extend(kept);
252        differing = rest;
253    }
254    (missing, differing, same)
255}
256
257fn names(locals: &[&Local]) -> Vec<String> {
258    locals.iter().map(|local| local.name.clone()).collect()
259}
260
261fn paths(target: &Utf8Path, locals: &[&Local]) -> Vec<Utf8PathBuf> {
262    locals
263        .iter()
264        .map(|local| target.join(&local.path))
265        .collect()
266}
267
268/// Refuse a file the forge holds with other bytes.
269fn refuse_differing(differing: &[&Local], tag: &str, place: &str) -> Result<(), RkError> {
270    if differing.is_empty() {
271        return Ok(());
272    }
273    Err(super::conflict(
274        format!(
275            "{place} of {tag} holds {} with other bytes than this build",
276            names(differing).join(", ")
277        ),
278        super::RECOVERY_DEFECTIVE,
279    ))
280}
281
282/// GitHub: a draft at the commit, every asset, then the publish, which
283/// creates the tag. Answers whether a release already existed.
284fn github(
285    client: &Client,
286    target: &Utf8Path,
287    head: &str,
288    build: &BuildReport,
289    notes: &str,
290    locals: &[Local],
291    report: &mut PublishReport,
292) -> Result<bool, RkError> {
293    let tag = &build.tag;
294    let existing = client.release(tag)?;
295    // SATISFIES release-engine:the-tag-is-created-last
296    let Some(release) = existing else {
297        let id = client.create_draft(tag, head, notes, build.candidate)?;
298        let all: Vec<&Local> = locals.iter().collect();
299        client.upload(tag, &paths(target, &all), false)?;
300        client.publish_draft(&id, head, build.candidate)?;
301        report.uploaded = names(&all);
302        return Ok(false);
303    };
304    let (missing, differing, same) = sort(locals, &release.assets, !release.draft);
305    report.kept = names(&same);
306    // SATISFIES release-engine:every-step-converges
307    if release.draft {
308        client.upload(tag, &paths(target, &missing), false)?;
309        client.upload(tag, &paths(target, &differing), true)?;
310        client.publish_draft(&release.id, head, build.candidate)?;
311        report.uploaded = names(&missing);
312        report.replaced = names(&differing);
313        return Ok(true);
314    }
315    refuse_differing(&differing, tag, "the published release")?;
316    client.upload(tag, &paths(target, &missing), false)?;
317    report.uploaded = names(&missing);
318    Ok(true)
319}
320
321/// GitLab: every package file first, then one release create with every
322/// link, which creates the tag. Answers whether a release or a package
323/// file already existed.
324fn gitlab(
325    client: &Client,
326    target: &Utf8Path,
327    head: &str,
328    build: &BuildReport,
329    notes: &str,
330    locals: &[Local],
331    report: &mut PublishReport,
332) -> Result<bool, RkError> {
333    let tag = &build.tag;
334    let version = Version::from_tag(tag).map_or_else(
335        || tag.trim_start_matches('v').to_owned(),
336        |v| v.spelled("semver"),
337    );
338    let stored = client.package_files(&version)?;
339    let (missing, differing, same) = sort(locals, &stored, true);
340    refuse_differing(&differing, tag, "the package registry")?;
341    // SATISFIES release-engine:every-step-converges
342    for local in &missing {
343        client.upload_package(&version, &target.join(&local.path))?;
344    }
345    report.uploaded = names(&missing);
346    report.kept = names(&same);
347    let links: Vec<(String, String)> = locals
348        .iter()
349        .map(|local| {
350            (
351                local.name.clone(),
352                client.package_url(&version, &local.name),
353            )
354        })
355        .collect();
356    let existed = !same.is_empty();
357    match client.release(tag)? {
358        None => {
359            let description = if build.candidate {
360                // SATISFIES release-engine:a-candidate-is-a-prerelease
361                let notice =
362                    super::forge_text("blocks/release-candidate-notice.md.in", &[("RK_TAG", tag)])?;
363                let mut text = notice;
364                text.push('\n');
365                text.push('\n');
366                text.push_str(notes);
367                text
368            } else {
369                notes.to_owned()
370            };
371            client.create_release(tag, head, &description, &links)?;
372            report.linked = links.iter().map(|(name, _)| name.clone()).collect();
373            Ok(existed)
374        }
375        Some(release) => {
376            for (name, url) in &links {
377                if !release.assets.iter().any(|asset| asset.name == *name) {
378                    client.add_link(tag, name, url)?;
379                    report.linked.push(name.clone());
380                }
381            }
382            Ok(true)
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::{Asset, Local, sort};
390
391    fn local(name: &str, sha256: &str) -> Local {
392        Local {
393            path: format!("dist/{name}").into(),
394            name: name.to_owned(),
395            sha256: sha256.to_owned(),
396        }
397    }
398
399    fn held(name: &str, sha256: &str) -> Asset {
400        Asset {
401            name: name.to_owned(),
402            sha256: Some(sha256.to_owned()),
403        }
404    }
405
406    fn named(locals: &[&Local]) -> Vec<String> {
407        locals.iter().map(|local| local.name.clone()).collect()
408    }
409
410    /// SATISFIES release-engine:every-step-converges
411    #[test]
412    fn a_held_bundle_is_kept_only_beside_a_matching_artifact() {
413        let locals = [
414            local("w.tar.gz", "aa"),
415            local("w.tar.gz.sigstore.json", "b2"),
416        ];
417        let matching = [held("w.tar.gz", "aa"), held("w.tar.gz.sigstore.json", "b1")];
418        let (missing, differing, same) = sort(&locals, &matching, true);
419        assert!(missing.is_empty() && differing.is_empty());
420        assert_eq!(named(&same), ["w.tar.gz", "w.tar.gz.sigstore.json"]);
421        // On a draft the bundle differs like any file.
422        let (_, differing, _) = sort(&locals, &matching, false);
423        assert_eq!(named(&differing), ["w.tar.gz.sigstore.json"]);
424        // Beside an artifact with other bytes, both refuse.
425        let other = [held("w.tar.gz", "00"), held("w.tar.gz.sigstore.json", "b1")];
426        let (_, differing, same) = sort(&locals, &other, true);
427        assert!(same.is_empty());
428        assert_eq!(named(&differing), ["w.tar.gz", "w.tar.gz.sigstore.json"]);
429    }
430}