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