Skip to main content

release_kit/release_engine/
build.rs

1//! `rk release build`: decide whether this push releases, and build it.
2//!
3//! The commit decides first, offline: the landing, the baseline, and a
4//! push that is no release trigger skip. The forge decides the rest: a
5//! version released at another commit skips, a release or a tag at this
6//! commit resumes, and a tag at another commit refuses. A release
7//! builds through the adapter, compares the rebuilt files with what the
8//! registry already serves, and writes its checksums, its notes, and its
9//! report beside `dist/`, never into it.
10
11use camino::{Utf8Path, Utf8PathBuf};
12use serde::{Deserialize, Serialize};
13
14use crate::detect::Forge;
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::error::RkError;
17use crate::release_state::Registry;
18
19use super::adapter::{self, Version};
20use super::forge::Client;
21use super::guard::{self, Existing, Outcome, Parent, Push};
22use super::packed;
23use super::{BUILD_REPORT, CHECKSUMS, Context, Done, NOTES, OUT_DIR, cliff};
24
25/// The report's shape version.
26pub const SCHEMA: &str = "rk.release-build/2";
27
28/// One artifact the build produced.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Artifact {
31    /// Its path, relative to the checkout.
32    pub path: String,
33    /// Its SHA-256, as lowercase hex.
34    pub sha256: String,
35}
36
37/// The machine form of `rk release build`, also written to
38/// `.rk-release/build.json` for `rk release publish` and the jobs between.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct BuildReport {
41    /// The shape version of this document.
42    pub schema: String,
43    /// `release`, `resume`, or `skip`, or `list` for `--list`.
44    pub state: String,
45    /// Why the push skips, where it does.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub reason: Option<String>,
48    /// The version, in the driver's spelling.
49    pub version: String,
50    /// The tag the version releases as.
51    pub tag: String,
52    /// Whether the version is a release candidate.
53    pub candidate: bool,
54    /// The commit the build ran at.
55    pub commit: String,
56    /// Every artifact, empty under `skip`.
57    pub artifacts: Vec<Artifact>,
58    /// The checksum file, under a release.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub checksums: Option<String>,
61    /// The notes file, under a release.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub notes: Option<String>,
64    /// The files the package holds at `HEAD`, under `list`.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub files: Option<Vec<String>>,
67}
68
69/// Decide, build, and report.
70///
71/// # Errors
72///
73/// A refusal of a conflicting tag, an unread tag, or a rebuild that
74/// differs from the registry; a build failure.
75pub fn run(target: &Utf8Path) -> Result<Done<BuildReport>, RkError> {
76    let context = Context::load(target)?;
77    context.require_tools("build")?;
78    cliff::require_pin(target)?;
79    let head = context.head()?;
80    let version = context.adapter.read_version(target)?;
81    let tag = context.adapter.tag_of(version);
82    let mut report = BuildReport {
83        schema: SCHEMA.to_owned(),
84        state: "skip".to_owned(),
85        reason: None,
86        version: version.spelled(context.driver()),
87        tag: tag.clone(),
88        candidate: version.is_candidate(),
89        commit: head.clone(),
90        artifacts: Vec::new(),
91        checksums: None,
92        notes: None,
93        files: None,
94    };
95    let outcome = decide(&context, &head, version, &tag)?;
96    match outcome {
97        Outcome::Skip(reason) => {
98            report.reason = Some(reason.clone());
99            write_report(&context, &report)?;
100            return Ok(Done {
101                report,
102                lines: vec![format!("skip: {reason}")],
103                next: Vec::new(),
104            });
105        }
106        Outcome::Refuse(reason) => {
107            return Err(super::conflict(reason, super::RECOVERY_TAG));
108        }
109        Outcome::Release | Outcome::Resume => {}
110    }
111    outcome.as_str().clone_into(&mut report.state);
112    let epoch = super::git_line(
113        target,
114        &["log", "-1", "--format=%ct", "HEAD"],
115        "the commit time",
116    )?;
117    let built = context.adapter.build(target, &epoch)?;
118    let mut artifacts = Vec::new();
119    for path in &built {
120        artifacts.push(Artifact {
121            path: path.to_string(),
122            sha256: super::file_sha256(&target.join(path))?,
123        });
124    }
125    if context.registry == Some(Registry::Pypi)
126        && let Some(package) = context.package.as_deref()
127    {
128        compare_with_registry(package, version, &artifacts)?;
129    }
130    let out = target.join(OUT_DIR);
131    if out.exists() {
132        std::fs::remove_dir_all(&out).map_err(RkError::Io)?;
133    }
134    std::fs::create_dir_all(&out).map_err(RkError::Io)?;
135    let notes = Utf8PathBuf::from(format!("{OUT_DIR}/{NOTES}"));
136    // SATISFIES release-engine:a-request-follows-the-packed-files
137    // The notes take the request changelog's paths: the files the release
138    // packs here or at the previous release, and the configuration where
139    // its declaration changed the packed set.
140    let paths = match packed::previous_tag(target, &tag)? {
141        Some(previous) => packed::compare(&context, &previous)?.history,
142        None => Vec::new(),
143    };
144    cliff::notes(target, &tag, &target.join(&notes), &paths)?;
145    let checksums = Utf8PathBuf::from(format!("{OUT_DIR}/{CHECKSUMS}"));
146    let mut sums = String::new();
147    for artifact in &artifacts {
148        sums.push_str(&artifact.sha256);
149        sums.push_str("  ");
150        sums.push_str(&super::file_name(Utf8Path::new(&artifact.path)));
151        sums.push('\n');
152    }
153    std::fs::write(target.join(&checksums), sums).map_err(RkError::Io)?;
154    report.artifacts = artifacts;
155    report.checksums = Some(checksums.to_string());
156    report.notes = Some(notes.to_string());
157    write_report(&context, &report)?;
158    let mut lines = vec![format!(
159        "{}: {tag} at {head}{}",
160        report.state,
161        if report.candidate {
162            ", a release candidate"
163        } else {
164            ""
165        }
166    )];
167    lines.extend(
168        report
169            .artifacts
170            .iter()
171            .map(|artifact| format!("built {} sha256 {}", artifact.path, artifact.sha256)),
172    );
173    Ok(Done {
174        report,
175        lines,
176        next: vec!["attest the artifacts, then rk release publish".to_owned()],
177    })
178}
179
180/// List the files the package holds at `HEAD`, as `cargo package --list`
181/// does.
182///
183/// A bash release lists its declared set, and a python release the
184/// tracked members of its sdist, built into a temporary directory. It
185/// writes nothing, reads no forge, and decides no release.
186///
187/// SATISFIES release-engine:the-bash-release-packs-its-declaration
188///
189/// # Errors
190///
191/// A refusal of the record, or the listing's failure.
192pub fn list(target: &Utf8Path) -> Result<Done<BuildReport>, RkError> {
193    let context = Context::load(target)?;
194    let head = context.head()?;
195    let version = context.adapter.read_version(target)?;
196    let files: Vec<String> = context
197        .adapter
198        .packed(target, "HEAD")?
199        .into_iter()
200        .collect();
201    Ok(Done {
202        lines: files.clone(),
203        report: BuildReport {
204            schema: SCHEMA.to_owned(),
205            state: "list".to_owned(),
206            reason: None,
207            version: version.spelled(context.driver()),
208            tag: context.adapter.tag_of(version),
209            candidate: version.is_candidate(),
210            commit: head,
211            artifacts: Vec::new(),
212            checksums: None,
213            notes: None,
214            files: Some(files),
215        },
216        next: Vec::new(),
217    })
218}
219
220/// The outcome of this push.
221fn decide(context: &Context, head: &str, version: Version, tag: &str) -> Result<Outcome, RkError> {
222    let push = Push {
223        version,
224        parent: parent_version(context)?,
225        merges_request: true,
226    };
227    if let Some(reason) = guard::trigger(push) {
228        return Ok(Outcome::Skip(reason));
229    }
230    let Some(base) = context
231        .pushed_branch()
232        .filter(|branch| context.is_base(branch))
233    else {
234        return Ok(Outcome::Skip(
235            "the push is to neither the trunk nor a release line".to_owned(),
236        ));
237    };
238    let client = Client::resolve(context.forge, &context.target, &context.repo)?;
239    if !version.is_candidate() {
240        let merges = client.merges_request(head, &base, &super::request_branch(&base))?;
241        if let Some(reason) = guard::trigger(Push {
242            merges_request: merges,
243            ..push
244        }) {
245            return Ok(Outcome::Skip(reason));
246        }
247    }
248    forge_outcome(&client, head, tag)
249}
250
251/// The outcome once the forge answered for the release and the tag of
252/// `tag`; `rk release publish` asks it again before it writes.
253///
254/// # Errors
255///
256/// A refusal where the release list cannot be read.
257pub fn forge_outcome(client: &Client, head: &str, tag: &str) -> Result<Outcome, RkError> {
258    let existing = match client.release(tag)? {
259        None => Existing::None,
260        Some(release) if release.draft => Existing::Draft,
261        Some(_) => Existing::Published,
262    };
263    let at = client.tag(tag);
264    Ok(guard::judge(head, tag, existing, &at))
265}
266
267/// The version the parent commit carries: `None` where the parent holds
268/// no version file, or where the commit has no parent.
269fn parent_version(context: &Context) -> Result<Parent, RkError> {
270    let (file, _) = adapter::version_file(context.driver()).unwrap_or(("VERSION", None));
271    let spec = format!("HEAD^:{file}");
272    let output = super::git(&context.target, &["show", &spec])?;
273    if !output.status.success() {
274        return Ok(Parent::Absent);
275    }
276    let text = String::from_utf8_lossy(&output.stdout);
277    Ok(Parent::Carries(
278        adapter::version_in(context.driver(), &text)
279            .as_deref()
280            .and_then(Version::parse),
281    ))
282}
283
284/// Refuse a rebuilt file whose name the registry already serves for this
285/// version with other bytes, before any upload or forge write.
286///
287/// SATISFIES release-engine:every-step-converges
288fn compare_with_registry(
289    package: &str,
290    version: Version,
291    artifacts: &[Artifact],
292) -> Result<(), RkError> {
293    let spelled = version.spelled("python");
294    let served = crate::release_state::registry_files(Registry::Pypi, package, &spelled)
295        .map_err(|reason| {
296            RkError::refusal(
297                Diagnostic::new(
298                    Reason::ObservationIncomplete,
299                    format!("PyPI did not answer for {package} {spelled}: {reason}"),
300                )
301                .expected("PyPI's JSON API answering 200 or 404 for the version")
302                .action("rerun the job once PyPI answers")
303                .target_state("no upload and no forge write ran"),
304            )
305        })?
306        .unwrap_or_default();
307    for artifact in artifacts {
308        let name = super::file_name(Utf8Path::new(&artifact.path));
309        if let Some((_, digest)) = served.iter().find(|(file, _)| *file == name)
310            && *digest != artifact.sha256
311        {
312            return Err(super::conflict(
313                format!(
314                    "PyPI serves {name} for {package} {spelled} with sha256 {digest}, and this rebuild has {}; yank the version and fix forward",
315                    artifact.sha256
316                ),
317                super::RECOVERY_DEFECTIVE,
318            ));
319        }
320    }
321    Ok(())
322}
323
324/// Write the report into the output directory, and on GitHub into
325/// `$GITHUB_OUTPUT`, where the next steps read it.
326fn write_report(context: &Context, report: &BuildReport) -> Result<(), RkError> {
327    let out = context.target.join(OUT_DIR);
328    std::fs::create_dir_all(&out).map_err(RkError::Io)?;
329    let json = serde_json::to_string_pretty(report).map_err(|error| anyhow::anyhow!(error))?;
330    std::fs::write(out.join(BUILD_REPORT), format!("{json}\n")).map_err(RkError::Io)?;
331    if context.forge == Forge::Github
332        && let Some(path) = std::env::var_os("GITHUB_OUTPUT").filter(|path| !path.is_empty())
333    {
334        use std::io::Write as _;
335        let paths: Vec<&str> = report
336            .artifacts
337            .iter()
338            .map(|artifact| artifact.path.as_str())
339            .collect();
340        let delimiter = format!("rk-release-{}", report.commit);
341        let mut block = vec![
342            format!("state={}", report.state),
343            format!("version={}", report.version),
344            format!("tag={}", report.tag),
345            format!("candidate={}", report.candidate),
346            format!("artifacts<<{delimiter}"),
347        ];
348        block.extend(paths.iter().map(|path| (*path).to_owned()));
349        block.push(delimiter);
350        let mut file = std::fs::OpenOptions::new()
351            .create(true)
352            .append(true)
353            .open(path)
354            .map_err(RkError::Io)?;
355        for line in block {
356            writeln!(file, "{line}").map_err(RkError::Io)?;
357        }
358    }
359    Ok(())
360}