Skip to main content

release_kit/commands/
stage.rs

1//! `rk stage`: write the candidate this binary would land into a
2//! disposable stage, and `rk stage clean`: remove exactly one such stage.
3//!
4//! The create verb reads the target the way the landing verbs do, resolves
5//! the same parameters, computes the one pure projection, and materializes
6//! it under the resolved output. It writes nothing inside the target, and
7//! nothing about it changes what a production landing does: `rk init`,
8//! `rk upgrade`, `rk adopt`, and `rk status` never read a stage. The
9//! clean verb removes one directory whose own receipt names it, through
10//! held descriptors, and refuses everything else.
11
12use camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::cli::stage::{StageAction, StageArgs};
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::error::RkError;
18use crate::landing::manifest::{self, Style, Workflow};
19use crate::landing::{self, Params};
20use crate::output::Output;
21use crate::projection::{Projection, ProjectionInput, TargetEvidence};
22use crate::release::EmbeddedReleaseSource;
23use crate::stage::{self, OutputSource, Receipt, clean};
24
25/// The machine form of a staging: the receipt as written, plus where the
26/// output path came from and what follows.
27#[derive(Debug, Serialize)]
28struct Report<'a> {
29    /// The receipt, its `schema` first.
30    #[serde(flatten)]
31    receipt: &'a Receipt,
32    /// `--output`, `RK_STAGE_ROOT`, or `state root`.
33    output_source: &'static str,
34    /// What plausibly follows.
35    next: Vec<String>,
36}
37
38/// The machine form of a cleanup.
39#[derive(Debug, Serialize)]
40struct CleanReport {
41    /// The shape version of this document.
42    schema: &'static str,
43    /// The stage that was removed.
44    stage_root: String,
45    /// The target its receipt named.
46    target: String,
47    /// Whether the directory is gone.
48    removed: bool,
49    /// The command that recreates it.
50    recovery: String,
51    /// What plausibly follows.
52    next: Vec<String>,
53}
54
55/// Stage the candidate, or remove a stage.
56///
57/// # Errors
58///
59/// Returns [`RkError::Missing`] for a target that is not a directory, the
60/// parameter resolution's own failures, a `destructive-refusal` for a
61/// stage root inside the target, a `state-drift` refusal for an existing
62/// nonempty output, and [`RkError::Io`] for a write that fails;
63/// `clean` returns the refusals `crate::stage::clean::validate` names.
64pub fn run(args: &StageArgs) -> Result<(), RkError> {
65    match &args.action {
66        Some(StageAction::Clean { path, json }) => remove(Output::new(*json), path),
67        None => create(args),
68    }
69}
70
71fn create(args: &StageArgs) -> Result<(), RkError> {
72    let out = Output::new(args.json);
73    if !args.target.is_dir() {
74        return Err(RkError::missing(
75            Diagnostic::new(
76                Reason::TargetNotFound,
77                format!("target {} is not a directory", args.target),
78            )
79            .expected("an existing repository to stage the candidate for"),
80        ));
81    }
82    let target = canonical(&args.target)?;
83    let config = crate::config::load(target.as_std_path())?;
84    // The record explains the target to the projection; a record this
85    // binary cannot read is reported and stands aside, because a stage is
86    // evidence and refusing to explain would leave the agent with less.
87    let record = match manifest::load(&target) {
88        Ok(record) => record,
89        Err(error) => {
90            out.warn(format!(
91                "the landing record was not read, so the candidate is computed as a first landing: {error}"
92            ));
93            None
94        }
95    };
96    let receipt_schema_version = stage::recorded_schema_version(&target);
97    let params = Params::resolve(
98        &EmbeddedReleaseSource,
99        &target,
100        &landing::Inputs {
101            tech: args.tech.as_deref(),
102            forge: args.forge.as_deref(),
103            repo: args.repo.as_deref(),
104            workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
105            style: args.style.as_deref().map(Style::parse).transpose()?,
106            nix: args.nix.then_some(true),
107        },
108        config.as_ref(),
109        record.as_ref(),
110        if record.is_some() {
111            landing::Purpose::Upgrade
112        } else {
113            landing::Purpose::Init
114        },
115    )?;
116    let evidence = TargetEvidence::gather(&target, record.as_ref())?;
117    let projection = Projection::compute(&ProjectionInput {
118        params: params.clone(),
119        evidence,
120    })?;
121    let (output, source) = stage::resolve_output(args.output.as_deref(), target.as_std_path())?;
122    let prepared = stage::prepare(&output, source, target.as_std_path())?;
123    let composed = stage::compose(
124        &projection,
125        &params,
126        target.as_std_path(),
127        prepared.resolved(),
128        record.as_ref(),
129        receipt_schema_version,
130    );
131    stage::write(&prepared, &composed)?;
132    let receipt = &composed.receipt;
133    let next = vec![
134        format!(
135            "read {}/{} for the candidate inventory and its reasons",
136            receipt.stage_root,
137            stage::RECEIPT_NAME
138        ),
139        format!(
140            "compare {}/{} with {} before anything lands",
141            receipt.stage_root,
142            stage::ARTIFACTS_DIR,
143            receipt.target
144        ),
145        format!(
146            "rk stage clean {} removes the stage once the landing is verified",
147            receipt.stage_root
148        ),
149    ];
150    render(out, receipt, source);
151    out.next(&next);
152    out.emit(&Report {
153        receipt,
154        output_source: source.as_str(),
155        next,
156    })
157}
158
159/// The human report, one fact per line.
160fn render(out: Output, receipt: &Receipt, source: OutputSource) {
161    out.result_line(format!("stage: {}", receipt.stage_root));
162    out.result_line(format!("output: from {}", source.as_str()));
163    out.result_line(format!("target: {}", receipt.target));
164    let parameters = &receipt.parameters;
165    out.result_line(format!(
166        "parameters: tech {}, forge {}, repo {}, workflow {}, style {}, nix {}",
167        parameters.tech,
168        parameters.forge,
169        parameters.repo,
170        parameters.workflow.as_str(),
171        parameters.style.map_or("unresolved", Style::as_str),
172        if parameters.nix { "on" } else { "off" }
173    ));
174    out.result_line(format!(
175        "landing record: {}",
176        receipt.receipt_schema_version.map_or_else(
177            || "none".to_owned(),
178            |version| format!("schema_version {version}")
179        )
180    ));
181    out.result_line(format!("candidates: {}", receipt.candidates.len()));
182    for candidate in &receipt.candidates {
183        out.result_line(format!(
184            "  {}/{} ({}, {})",
185            stage::ARTIFACTS_DIR,
186            candidate.destination,
187            candidate.kind.as_str(),
188            candidate.placement
189        ));
190    }
191    for note in &receipt.omissions {
192        out.result_line(format!("omitted {}: {}", note.destination, note.reason));
193    }
194    for note in &receipt.collisions {
195        out.result_line(format!("collision {}: {}", note.destination, note.reason));
196    }
197    for destination in &receipt.retired {
198        out.result_line(format!(
199            "retired {destination}: recorded, no longer produced; target-owned from the next landing"
200        ));
201    }
202    for destination in &receipt.seeded_present {
203        out.result_line(format!("seeded present {destination}: a landing keeps it"));
204    }
205    for destination in &receipt.state_present {
206        out.result_line(format!("state present {destination}: a landing keeps it"));
207    }
208    out.result_line(format!(
209        "reference: {}",
210        receipt
211            .reference
212            .iter()
213            .map(|root| format!("{}/{root}", stage::REFERENCE_DIR))
214            .collect::<Vec<_>>()
215            .join(", ")
216    ));
217}
218
219fn remove(out: Output, path: &Utf8Path) -> Result<(), RkError> {
220    let validated = clean::validate(path.as_std_path())?;
221    clean::remove(&validated)?;
222    let stage_root = validated.resolved().display().to_string();
223    let target = validated.target().to_owned();
224    let recovery = format!("rk stage --target {target} --output {stage_root}");
225    out.result_line(format!("removed {stage_root}"));
226    out.result_line(format!("recovery: {recovery} stages the candidate again"));
227    let next = vec![
228        format!("rk status --target {target} reports what the target holds"),
229        format!("rk stage --target {target} stages this binary's candidate again"),
230    ];
231    out.next(&next);
232    out.emit(&CleanReport {
233        schema: "rk.stage-clean/1",
234        stage_root,
235        target,
236        removed: true,
237        recovery,
238        next,
239    })
240}
241
242/// The canonical absolute target, as text.
243fn canonical(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
244    let path = std::fs::canonicalize(target)?;
245    Utf8PathBuf::from_path_buf(path)
246        .map_err(|path| RkError::Usage(format!("the target path is not UTF-8: {}", path.display())))
247}
248
249#[cfg(test)]
250mod tests {
251    use super::{CleanReport, Report};
252    use crate::stage::{Parameters, Receipt, STAGE_SCHEMA};
253
254    /// The `rk stage --json` report is the receipt with the output source
255    /// and the next lines beside it, held by snapshot.
256    #[test]
257    fn the_stage_report_schema_snapshot_holds() {
258        let receipt = Receipt {
259            schema: STAGE_SCHEMA.to_owned(),
260            rk_version: "0.0.0".into(),
261            target: "/tmp/t".into(),
262            stage_root: "/tmp/s".into(),
263            parameters: Parameters {
264                tech: "rust".into(),
265                forge: "github".into(),
266                repo: "acme/widget".into(),
267                workflow: crate::landing::Workflow::Branches,
268                style: None,
269                nix: true,
270                trunk: "main".into(),
271                line_prefix: "release/".into(),
272                security_contact: String::new(),
273                security_response: "best-effort".into(),
274            },
275            receipt_schema_version: None,
276            candidates: vec![],
277            omissions: vec![],
278            collisions: vec![],
279            retired: vec![],
280            seeded_present: vec![],
281            state_present: vec![],
282            reference: vec!["CHANGELOG.md".into()],
283        };
284        let report = Report {
285            receipt: &receipt,
286            output_source: "--output",
287            next: vec![
288                "rk stage clean /tmp/s removes the stage once the landing is verified".into(),
289            ],
290        };
291        assert_eq!(
292            serde_json::to_string(&report).expect("a report serializes"),
293            r#"{"schema":"rk.stage/1","rk_version":"0.0.0","target":"/tmp/t","stage_root":"/tmp/s","parameters":{"tech":"rust","forge":"github","repo":"acme/widget","workflow":"branches","style":null,"nix":true,"trunk":"main","line_prefix":"release/","security_contact":"","security_response":"best-effort"},"receipt_schema_version":null,"candidates":[],"omissions":[],"collisions":[],"retired":[],"seeded_present":[],"state_present":[],"reference":["CHANGELOG.md"],"output_source":"--output","next":["rk stage clean /tmp/s removes the stage once the landing is verified"]}"#
294        );
295    }
296
297    /// The complete `rk.stage-clean/1` shape, held by snapshot.
298    #[test]
299    fn the_stage_clean_report_schema_snapshot_holds() {
300        let report = CleanReport {
301            schema: "rk.stage-clean/1",
302            stage_root: "/tmp/s".into(),
303            target: "/tmp/t".into(),
304            removed: true,
305            recovery: "rk stage --target /tmp/t --output /tmp/s".into(),
306            next: vec!["rk status --target /tmp/t reports what the target holds".into()],
307        };
308        assert_eq!(
309            serde_json::to_string(&report).expect("a report serializes"),
310            r#"{"schema":"rk.stage-clean/1","stage_root":"/tmp/s","target":"/tmp/t","removed":true,"recovery":"rk stage --target /tmp/t --output /tmp/s","next":["rk status --target /tmp/t reports what the target holds"]}"#
311        );
312    }
313}