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