Skip to main content

release_kit/commands/
upgrade.rs

1//! `rk upgrade`: a landed target takes this binary's projection.
2//!
3//! A front over the direct writer: the receipt is required, the target's
4//! evidence is gathered once, this binary's projection is computed from
5//! its embedded sources, and every destination is decided by its recorded
6//! kind alone. A recorded generated file is replaced from the candidate
7//! whatever its bytes are, because the operator and the agent authorized
8//! the migration and Git holds the recovery; a recorded seeded or state
9//! file is preserved with its current digest entering the receipt; a
10//! recorded marked region is replaced alone; a recorded destination the
11//! projection no longer produces is released to the target. A whole-file
12//! destination present on disk and absent from the receipt refuses
13//! before any write, every collision collected in one pass. Preview by
14//! default; `--apply` lands under one target lock with the receipt last.
15//!
16//! SATISFIES landing:ownership-is-elementary
17//! SATISFIES landing:a-dropped-file-stays
18//! SATISFIES landing:a-target-is-never-downgraded
19
20use std::fmt::Write as _;
21
22use serde::Serialize;
23
24use crate::cli::upgrade::UpgradeArgs;
25use crate::diagnostic::{Diagnostic, Reason};
26use crate::embedded;
27use crate::error::RkError;
28use crate::held;
29use crate::landing::apply::{self, Action, Collision, Prepared};
30use crate::landing::manifest::{self, Alignment, Manifest, Provider};
31use crate::landing::{self, lock};
32use crate::output::Output;
33use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
34use crate::stage::CapabilityNote;
35
36/// One destination and what the upgrade decided for it.
37#[derive(Debug, Serialize)]
38struct FileEntry {
39    /// The destination, relative to the target.
40    path: String,
41    /// The kind this projection declares for it, or the recorded kind of
42    /// a released destination.
43    kind: &'static str,
44    /// `created`, `replaced`, `matched`, `preserved`, `drift`, `released`,
45    /// or `collision` in a preview.
46    action: &'static str,
47}
48
49/// The machine form of an upgrade report.
50#[derive(Debug, Serialize)]
51struct Report {
52    /// The shape version of this document.
53    schema: &'static str,
54    /// `preview` or `apply`.
55    mode: &'static str,
56    /// The target directory.
57    target: String,
58    /// The version the receipt came from.
59    from_version: String,
60    /// This binary's version.
61    to_version: &'static str,
62    /// What the project is, as the rewritten receipt carries it.
63    profile: ProfileSnapshot,
64    /// How topic branches reach the trunk.
65    git: GitWorkflow,
66    /// Which optional products the rewritten receipt carries.
67    capabilities: CapabilityRequests,
68    /// Every capability, in catalog order, with its status.
69    selection: Vec<CapabilityNote>,
70    /// Why the selected release automation cannot land in this release,
71    /// absent where it can or none is selected. A preview reports it and
72    /// exits 0; the apply refuses on it.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    release_unavailable: Option<String>,
75    /// Why the provider's licence condition refuses this target, absent
76    /// where no condition applies or the licence satisfies it. A preview
77    /// reports it and exits 0; the apply refuses on it.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    licence_refusal: Option<String>,
80    /// The Nix destinations this target could not take, each with why;
81    /// absent where nothing was withheld.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    withheld: Option<Vec<landing::Withheld>>,
84    /// The destinations the landing refuses as they stand; absent where
85    /// there is none. A preview lists them and exits 0.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    collisions: Option<Vec<Collision>>,
88    config: crate::config::Plan,
89    /// Every destination, with its action.
90    files: Vec<FileEntry>,
91    /// What plausibly follows.
92    next: Vec<String>,
93}
94
95/// Upgrade the landed target to this binary's projection.
96///
97/// # Errors
98///
99/// Returns a refusal for a missing receipt, an unknown receipt schema, a
100/// receipt from a newer binary, and, on apply, any collected collision;
101/// and [`RkError::Io`] on filesystem failure.
102pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
103    let out = Output::new(args.json);
104    // An apply takes the target before it reads anything of it, the
105    // receipt and the configuration included. A preview holds nothing.
106    let lock = args
107        .apply
108        .then(|| lock::acquire(&args.target))
109        .transpose()?;
110    // One directory descriptor, held from here through the receipt write:
111    // every read and every write goes through it, so a root exchanged
112    // under the pathname later receives nothing.
113    // The proof's pause: the lock is held and the directory is not yet.
114    held::pause(apply::PAUSE_VAR, "locked", "proceed-locked");
115    let held = lock.as_ref().map_or_else(
116        || apply::Held::open(&args.target),
117        |lock| apply::Held::open_locked(&args.target, lock),
118    )?;
119    // The proof's pause: the target is held, and nothing has been read.
120    held::pause(apply::PAUSE_VAR, "held", "proceed-held");
121    let recorded = load_upgradable(&held)?;
122    let existing = crate::config::load(held.base().as_std_path())?;
123    let params = resolve_params(args, &held, &recorded, existing.as_ref())?;
124
125    let (prepared, landed) = if let Some(lock) = lock {
126        let prepared = apply::prepare(&held, Some(&recorded), &params, existing.as_ref())?;
127        let landed = apply::land(
128            &held,
129            Some(&recorded),
130            &prepared,
131            apply::Origin::Upgrade,
132            &lock,
133        )?;
134        drop(lock);
135        (prepared, Some(landed))
136    } else {
137        (
138            apply::prepare(&held, Some(&recorded), &params, existing.as_ref())?,
139            None,
140        )
141    };
142
143    let sentinels = report_decisions(out, &held, &prepared, landed.is_some())?;
144    if landed.is_some() {
145        out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
146        for sentinel in &sentinels {
147            out.result_line(format!("fill this sentinel: {sentinel}"));
148        }
149    }
150
151    let next = next_lines(args, &params, prepared.collisions.is_empty());
152    out.next(&next);
153    out.emit(&Report {
154        schema: "rk.upgrade/10",
155        config: prepared.config.clone(),
156        mode: if args.apply { "apply" } else { "preview" },
157        target: args.target.to_string(),
158        from_version: recorded.rk_version,
159        to_version: env!("CARGO_PKG_VERSION"),
160        profile: params.profile().clone(),
161        git: params.git().clone(),
162        capabilities: params.capabilities().clone(),
163        selection: prepared
164            .projection
165            .capabilities
166            .iter()
167            .map(|selection| CapabilityNote::of(selection, &prepared.projection))
168            .collect(),
169        release_unavailable: prepared.projection.release_unavailable().map(str::to_owned),
170        licence_refusal: prepared.projection.licence_refusal.clone(),
171        withheld: withheld_of(&prepared),
172        collisions: (!prepared.collisions.is_empty()).then(|| prepared.collisions.clone()),
173        files: prepared
174            .decisions
175            .iter()
176            .map(|decision| FileEntry {
177                path: decision.destination.clone(),
178                kind: decision.kind.as_str(),
179                action: decision.action.as_str(),
180            })
181            .chain(prepared.collisions.iter().map(|collision| FileEntry {
182                path: collision.path.clone(),
183                kind: landing::kind_of(&collision.path).map_or("unknown", landing::Kind::as_str),
184                action: "collision",
185            }))
186            .collect(),
187        next,
188    })
189}
190
191/// The withheld list a report carries.
192fn withheld_of(prepared: &Prepared) -> Option<Vec<landing::Withheld>> {
193    let withheld: Vec<landing::Withheld> = prepared
194        .projection
195        .omissions
196        .iter()
197        .map(|omission| landing::Withheld {
198            path: omission.destination.clone(),
199            reason: omission.reason.clone(),
200        })
201        .collect();
202    (!withheld.is_empty()).then_some(withheld)
203}
204
205/// Every human line one upgrade prints about its own decisions, and the
206/// sentinels a landed run found while it read them back.
207///
208/// `landed` says whether the run wrote: only then is a created or replaced
209/// destination read back for its unfilled sentinels, because a preview has
210/// nothing on disk to read.
211///
212/// # Errors
213///
214/// A read failure at a destination the run just wrote.
215fn report_decisions(
216    out: Output,
217    held: &apply::Held,
218    prepared: &Prepared,
219    landed: bool,
220) -> Result<Vec<String>, RkError> {
221    out.result_line(format!(
222        "profile: {}",
223        crate::commands::profile::describe(
224            prepared.params.profile(),
225            prepared.params.git(),
226            prepared.params.capabilities(),
227            prepared.params.repo()
228        )
229    ));
230    crate::commands::init::describe_selection(out, prepared);
231    for key in &prepared.config.changes {
232        out.result_line(format!("configuration changes {key}"));
233    }
234    out.result_line(format!(
235        "{} {}",
236        prepared.config.action,
237        crate::config::CONFIG_PATH
238    ));
239    let mut sentinels: Vec<String> = Vec::new();
240    for decision in &prepared.decisions {
241        if landed && matches!(decision.action, Action::Created | Action::Replaced) {
242            let bytes =
243                landing::read_recorded(held.base(), &decision.destination)?.unwrap_or_default();
244            collect_sentinels(&decision.destination, &bytes, &mut sentinels);
245        }
246        out.result_line(crate::commands::init::describe(decision));
247    }
248    for collision in &prepared.collisions {
249        out.result_line(format!(
250            "collision {}: {}",
251            collision.path, collision.reason
252        ));
253    }
254    for entry in &prepared.projection.omissions {
255        out.result_line(format!("withheld {}: {}", entry.destination, entry.reason));
256        if let Some(action) = &entry.action {
257            out.result_line(format!("  action: {action}"));
258        }
259    }
260    if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
261        out.result_line(format!("licence refusal: {reason}"));
262    }
263    if let Some(reason) = prepared.projection.release_unavailable() {
264        out.result_line(format!("release automation unavailable: {reason}"));
265    }
266    Ok(sentinels)
267}
268
269/// One capability flag's answer: `on`, `off`, or unanswered.
270///
271/// Every opt-in capability reads its flag the same way, so the refusal
272/// names the flag and the two values from one place.
273fn toggle(flag: &str, value: Option<&str>) -> Result<Option<bool>, RkError> {
274    match value {
275        None => Ok(None),
276        Some("on") => Ok(Some(true)),
277        Some("off") => Ok(Some(false)),
278        Some(other) => Err(RkError::Usage(format!(
279            "unknown --{flag} value '{other}'; the values are: on, off"
280        ))),
281    }
282}
283
284fn resolve_params(
285    args: &UpgradeArgs,
286    held: &apply::Held,
287    recorded: &Manifest,
288    existing: Option<&crate::config::Config>,
289) -> Result<landing::Params, RkError> {
290    let nix = toggle("nix-packaging", args.nix_packaging.as_deref())?;
291    let reporting_policy = toggle("reporting-policy", args.reporting_policy.as_deref())?;
292    let scorecard = toggle("scorecard", args.scorecard.as_deref())?;
293    let code_scanning = args
294        .code_scanning
295        .as_deref()
296        .map(Provider::parse)
297        .transpose()?;
298    landing::Params::resolve(
299        held.base(),
300        &landing::Inputs {
301            nix,
302            reporting_policy,
303            scorecard,
304            code_scanning,
305            ..args.profile.inputs()?
306        },
307        existing,
308        Some(recorded),
309        landing::Purpose::Upgrade,
310    )
311}
312
313/// The `Next:` lines for each outcome. A behavior-defining flag the
314/// preview was run with rides into the follow-up command, so following
315/// it applies the decision that was previewed, never a different one.
316fn next_lines(args: &UpgradeArgs, params: &landing::Params, clean: bool) -> Vec<String> {
317    // A flag the preview was run with rides into the follow-up, as the
318    // resolved answer it produced; a flag it was not run with stays out,
319    // so the configuration and the record keep answering it.
320    let profile = &args.profile;
321    let mut identity_flags = String::new();
322    for technology in params.technologies() {
323        if !profile.technology.is_empty() {
324            let _ = write!(identity_flags, " --technology {technology}");
325        }
326    }
327    for (given, flag, value) in [
328        (
329            profile.forge.is_some(),
330            "forge",
331            params.forge().map(str::to_owned),
332        ),
333        (
334            profile.repo.is_some(),
335            "repo",
336            Some(params.repo().to_owned()),
337        ),
338        (
339            profile.release_mode.is_some(),
340            "release-mode",
341            Some(params.release_mode().as_str().to_owned()),
342        ),
343        (
344            profile.release_driver.is_some(),
345            "release-driver",
346            params.driver().map(str::to_owned),
347        ),
348        (
349            profile.release_style.is_some(),
350            "release-style",
351            params.style().map(|style| style.as_str().to_owned()),
352        ),
353        (
354            profile.trunk.is_some(),
355            "trunk",
356            Some(params.trunk().to_owned()),
357        ),
358        (
359            profile.checkout_mode.is_some(),
360            "checkout-mode",
361            Some(params.checkout_mode().as_str().to_owned()),
362        ),
363    ] {
364        if given && let Some(value) = value {
365            let _ = write!(identity_flags, " --{flag} {value}");
366        }
367    }
368    let workflow_flag = String::new();
369    let style_flag = String::new();
370    let capabilities = params.capability_toggles();
371    if args.apply {
372        vec![
373            "commit the upgraded files, the receipt included".to_owned(),
374            format!("rk status --target {} reports the result", args.target),
375        ]
376    } else if clean {
377        vec![
378            format!(
379                "rk upgrade{identity_flags}{workflow_flag}{style_flag}{capabilities} --target {} --apply writes",
380                args.target
381            ),
382            format!(
383                "rk stage --target {} stages the complete candidate for a byte comparison",
384                args.target
385            ),
386        ]
387    } else {
388        vec![
389            format!(
390                "resolve each collision above through the rk-setup skill; rk upgrade{identity_flags}{workflow_flag}{style_flag}{capabilities} --target {} --apply refuses until then",
391                args.target
392            ),
393            format!(
394                "rk stage --target {} stages the complete candidate for a byte comparison",
395                args.target
396            ),
397        ]
398    }
399}
400
401/// The receipt an upgrade may act on: present, at a known schema, and not
402/// from a newer binary than this one.
403fn load_upgradable(held: &apply::Held) -> Result<Manifest, RkError> {
404    let target = held.display();
405    let Some(recorded) = manifest::load(held.base())? else {
406        return Err(RkError::refusal(
407            Diagnostic::new(
408                Reason::StateDrift,
409                format!(
410                    "no {} at {target}: an upgrade needs the receipt of the landing it moves, and nothing was written",
411                    manifest::MANIFEST_PATH
412                ),
413            )
414            .expected("a recorded landing")
415            .action(format!(
416                "rk stage --target {target} stages this binary's candidate for a byte comparison; the rk-setup skill carries the best-effort migration, ending in rk adopt for a target brought to the candidate or rk init for a fresh one"
417            ))
418            .target_state("unchanged"),
419        ));
420    };
421    if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
422        == Alignment::TargetNewer
423    {
424        return Err(RkError::refusal(
425            Diagnostic::new(
426                Reason::StateDrift,
427                format!(
428                    "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
429                    recorded.rk_version,
430                    env!("CARGO_PKG_VERSION")
431                ),
432            )
433            .expected("a binary at or above the recorded rk_version")
434            .action(format!("install release-kit {} or newer", recorded.rk_version))
435            .target_state("unchanged"),
436        ));
437    }
438    Ok(recorded)
439}
440
441/// The judgment sentinels a newly written file carries.
442fn collect_sentinels(destination: &str, bytes: &[u8], found: &mut Vec<String>) {
443    let text = String::from_utf8_lossy(bytes);
444    for (idx, line) in text.lines().enumerate() {
445        if line.contains(embedded::SENTINEL) {
446            found.push(format!("{destination}:{}: {}", idx + 1, line.trim()));
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::{FileEntry, Report};
454    use crate::landing::CheckoutMode;
455    use crate::landing::Integration;
456    use crate::profile::{
457        CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
458    };
459
460    /// The complete `rk.upgrade/10` shape, held by snapshot.
461    #[test]
462    fn the_upgrade_report_schema_snapshot_holds() {
463        let report = Report {
464            schema: "rk.upgrade/10",
465            config: crate::config::Plan {
466                action: "added",
467                changes: vec![],
468                content: "schema_version = 2\n".into(),
469            },
470            mode: "preview",
471            target: "/tmp/t".into(),
472            from_version: "0.1.0".into(),
473            to_version: "0.2.0",
474            profile: ProfileSnapshot {
475                technologies: vec!["rust".into()],
476                forge: Some("github".into()),
477                release: ReleaseIntent {
478                    mode: ReleaseMode::Automatic,
479                    driver: Some("rust".into()),
480                    style: Some(crate::landing::Style::Trunk),
481                    line_prefix: Some("release/".into()),
482                },
483            },
484            git: GitWorkflow {
485                trunk: "master".into(),
486                checkout_mode: CheckoutMode::MainWorktree,
487                integration: Integration::Local,
488            },
489            capabilities: CapabilityRequests {
490                nix_packaging: false,
491                reporting_policy: true,
492                scorecard: false,
493                code_scanning: None,
494            },
495            selection: vec![],
496            release_unavailable: None,
497            licence_refusal: None,
498            withheld: None,
499            collisions: None,
500            files: vec![
501                FileEntry {
502                    path: "release-plz.toml".into(),
503                    kind: "seeded",
504                    action: "drift",
505                },
506                FileEntry {
507                    path: "legacy.yml".into(),
508                    kind: "rendered",
509                    action: "released",
510                },
511            ],
512            next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
513        };
514        assert_eq!(
515            serde_json::to_string(&report).expect("a report serializes"),
516            r#"{"schema":"rk.upgrade/10","mode":"preview","target":"/tmp/t","from_version":"0.1.0","to_version":"0.2.0","profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"main-worktree","integration":"local"},"capabilities":{"nix_packaging":false,"reporting_policy":true,"scorecard":false},"selection":[],"config":{"action":"added","changes":[],"content":"schema_version = 2\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"drift"},{"path":"legacy.yml","kind":"rendered","action":"released"}],"next":["rk upgrade --target /tmp/t --apply writes"]}"#
517        );
518    }
519}