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