Skip to main content

release_kit/commands/
upgrade.rs

1//! `rk upgrade`: a landed target takes a newer payload.
2//!
3//! Three digests decide each file: the baseline the record keeps — the
4//! payload as it stood at landing — the bytes on disk now, and this
5//! binary's candidate, rendered under the recorded parameters. A
6//! `rendered` file nobody touched is rewritten; one the target edited is
7//! a conflict, and every conflict is collected before the whole upgrade
8//! refuses in one run. There is no merge: the two outcomes are a clean
9//! write and a refusal, because a wrong guess in a release workflow is
10//! discovered at the next release.
11
12use serde::Serialize;
13
14use crate::cli::upgrade::UpgradeArgs;
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::digest::Digest;
17use crate::error::RkError;
18use crate::landing::manifest::{self, Alignment, FileRecord, Manifest, Style, Workflow};
19use crate::landing::{self, Entry, Kind};
20use crate::output::Output;
21use crate::{embedded, registry};
22
23/// One destination and what the upgrade decided for it.
24#[derive(Debug, Serialize)]
25struct FileEntry {
26    /// The destination, relative to the target.
27    path: String,
28    /// The kind this payload declares for it.
29    kind: &'static str,
30    /// `updated`, `unchanged`, `added`, `drift`, `kept`, `dropped`,
31    /// `state`, or `conflict`.
32    action: &'static str,
33}
34
35/// The machine form of an upgrade report.
36#[derive(Debug, Serialize)]
37struct Report {
38    /// The shape version of this document.
39    schema: &'static str,
40    /// `preview` or `apply`.
41    mode: &'static str,
42    /// The target directory.
43    target: String,
44    /// The recorded technology.
45    tech: String,
46    /// The recorded forge.
47    forge: String,
48    /// The version the record came from.
49    from_version: String,
50    /// This binary's version.
51    to_version: &'static str,
52    /// The working-copy mode the rewritten record carries — the recorded
53    /// mode, or the `--workflow` override this run applies.
54    workflow: &'static str,
55    style: &'static str,
56    /// Whether the rewritten record carries the Nix capability.
57    nix: bool,
58    /// The Nix destinations this target could not take, each with why;
59    /// absent where nothing was withheld.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    withheld: Option<Vec<landing::Withheld>>,
62    /// Every destination, with its action.
63    files: Vec<FileEntry>,
64    /// What plausibly follows.
65    next: Vec<String>,
66}
67
68/// One decided destination, carried from the decision pass to the write
69/// pass and the record rewrite.
70struct Decision<'a> {
71    entry: Option<&'a Entry>,
72    action: &'static str,
73    record: FileRecord,
74}
75
76/// Upgrade the landed target to this binary's payload.
77///
78/// # Errors
79///
80/// Returns a refusal for a missing record, an unknown record schema, a
81/// record from a newer binary, a `rendered` destination that is not a
82/// regular file, and — on apply — any collected conflict; and
83/// [`RkError::Io`] on filesystem failure.
84pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
85    let out = Output::new(args.json);
86    let mut recorded = load_upgradable(&args.target)?;
87    // The mode change is an upgrade with exactly one overridden
88    // parameter; everything else — tech, forge, repo, lineage — comes
89    // from the record, untouched.
90    if let Some(raw) = args.workflow.as_deref() {
91        recorded.parameters.workflow = Workflow::parse(raw)?;
92    }
93    if let Some(raw) = args.style.as_deref() {
94        recorded.parameters.style = Some(Style::parse(raw)?);
95    }
96    // A pre-style record refuses rather than guessing: neither value is a
97    // compatibility-safe reading of a target nobody asked, because the
98    // style decides whether the landed release workflow arms the bot's
99    // request.
100    let Some(style) = recorded.parameters.style else {
101        return Err(RkError::Usage(
102            "the record carries no style parameter; pass --style <trunk|lines> — trunk arms the bot's release request to merge itself, lines keeps every merge a human's — and the upgrade records it".into(),
103        ));
104    };
105    resolve_nix(&mut recorded, args.nix.as_deref())?;
106    let (entries, withheld) = project(args, &recorded, style)?;
107    refuse_non_regular(&args.target, &entries)?;
108
109    let (decisions, conflicts) = decide_all(args, &recorded, &entries)?;
110    // A file this payload stops shipping is a file the target owns from
111    // that moment: left in place, named, and dropped from the record.
112    let mut dropped: Vec<String> = Vec::new();
113    for file in &recorded.files {
114        if !entries
115            .iter()
116            .any(|entry| entry.destination == file.destination)
117        {
118            dropped.push(file.destination.clone());
119        }
120    }
121
122    if args.apply && !conflicts.is_empty() {
123        return Err(refuse_conflicts(&conflicts));
124    }
125
126    let mut sentinels: Vec<String> = Vec::new();
127    for decision in &decisions {
128        if args.apply && matches!(decision.action, "updated" | "added") {
129            if let Some(entry) = decision.entry {
130                landing::write_destination(&args.target, entry)?;
131                collect_sentinels(entry, &mut sentinels);
132            }
133        }
134        out.result_line(match decision.action {
135            "drift" => format!(
136                "drift {} (seeded, target-owned)",
137                decision.record.destination
138            ),
139            "kept" => format!("kept {} (target-owned)", decision.record.destination),
140            "conflict" => format!(
141                "conflict {} (edited, release-kit-owned)",
142                decision.record.destination
143            ),
144            action => format!("{action} {}", decision.record.destination),
145        });
146    }
147    for path in &dropped {
148        out.result_line(format!(
149            "dropped {path} (no longer shipped; now target-owned)"
150        ));
151    }
152    for entry in &withheld {
153        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
154    }
155
156    if args.apply {
157        rewrite_record(&args.target, &recorded, &decisions)?;
158        out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
159        for sentinel in &sentinels {
160            out.result_line(format!("fill this sentinel: {sentinel}"));
161        }
162    }
163
164    let next = next_lines(args, conflicts.is_empty());
165    out.next(&next);
166    out.emit(&Report {
167        schema: "rk.upgrade/4",
168        mode: if args.apply { "apply" } else { "preview" },
169        target: args.target.to_string(),
170        tech: recorded.tech.clone(),
171        forge: recorded.forge.clone(),
172        from_version: recorded.rk_version.clone(),
173        to_version: env!("CARGO_PKG_VERSION"),
174        workflow: recorded.parameters.workflow.as_str(),
175        style: style.as_str(),
176        nix: recorded.parameters.nix,
177        withheld: (!withheld.is_empty()).then_some(withheld),
178        files: decisions
179            .iter()
180            .map(|decision| FileEntry {
181                path: decision.record.destination.clone(),
182                kind: decision.record.kind.as_str(),
183                action: decision.action,
184            })
185            .chain(dropped.iter().map(|path| FileEntry {
186                path: path.clone(),
187                kind: "dropped",
188                action: "dropped",
189            }))
190            .collect(),
191        next,
192    })
193}
194
195/// The Nix opt-in changes in either direction: `on` adds the capability's
196/// files and records it, `off` drops them from the record while the files
197/// stay the target's own. Omitted, the recorded choice is kept.
198fn resolve_nix(recorded: &mut Manifest, flag: Option<&str>) -> Result<(), RkError> {
199    match flag {
200        None => Ok(()),
201        Some("on") => {
202            recorded.parameters.nix = true;
203            Ok(())
204        }
205        Some("off") => {
206            recorded.parameters.nix = false;
207            Ok(())
208        }
209        Some(other) => Err(RkError::Usage(format!(
210            "unknown --nix value '{other}'; the values are: on, off"
211        ))),
212    }
213}
214
215/// The projection this record produces, with the Nix entries the target
216/// cannot take withheld exactly as a landing would withhold them.
217fn project(
218    args: &UpgradeArgs,
219    recorded: &Manifest,
220    style: Style,
221) -> Result<(Vec<landing::Entry>, Vec<landing::Withheld>), RkError> {
222    let mut entries = landing::projection(
223        &recorded.tech,
224        &recorded.forge,
225        &recorded.parameters.repo,
226        recorded.parameters.workflow,
227        Some(style),
228        recorded.parameters.nix,
229    )?;
230    let withheld = landing::withhold_nix(
231        &args.target,
232        recorded.parameters.nix,
233        Some(recorded),
234        &mut entries,
235    )?;
236    Ok((entries, withheld))
237}
238
239/// The collect-then-refuse conflict answer: the whole list in one run, so
240/// an operator resolves everything and re-runs once.
241fn refuse_conflicts(conflicts: &[String]) -> RkError {
242    RkError::refusal(
243        Diagnostic::new(
244            Reason::StateDrift,
245            format!(
246                "these files release-kit owns were edited, and nothing was written: {}",
247                conflicts.join(", ")
248            ),
249        )
250        .expected("every rendered file as the record left it")
251        .action("resolve each, or re-land it, then run 'rk upgrade' again")
252        .target_state("unchanged"),
253    )
254}
255
256/// The `Next:` lines for each outcome. A behavior-defining flag the
257/// preview was run with rides into the follow-up command, so following
258/// it applies the decision that was previewed, never a different one.
259fn next_lines(args: &UpgradeArgs, clean: bool) -> Vec<String> {
260    let workflow_flag = args
261        .workflow
262        .as_deref()
263        .map_or_else(String::new, |mode| format!(" --workflow {mode}"));
264    let style_flag = args
265        .style
266        .as_deref()
267        .map_or_else(String::new, |style| format!(" --style {style}"));
268    let nix_flag = args
269        .nix
270        .as_deref()
271        .map_or_else(String::new, |value| format!(" --nix {value}"));
272    if args.apply {
273        vec![
274            "commit the upgraded files, the record included".to_owned(),
275            format!("rk status --target {} reports the result", args.target),
276        ]
277    } else if clean {
278        vec![format!(
279            "rk upgrade{workflow_flag}{style_flag}{nix_flag} --target {} --apply writes",
280            args.target
281        )]
282    } else {
283        vec![format!(
284            "resolve each conflict above; rk upgrade{workflow_flag}{style_flag}{nix_flag} --target {} --apply refuses until then",
285            args.target
286        )]
287    }
288}
289
290/// The record after a successful apply, rewritten whole: new version, new
291/// digests, new pins; the first landing's instant, origin, and parameters
292/// are preserved.
293fn rewrite_record(
294    target: &camino::Utf8Path,
295    recorded: &Manifest,
296    decisions: &[Decision],
297) -> Result<(), RkError> {
298    manifest::write(
299        target,
300        &Manifest {
301            schema_version: manifest::SCHEMA_VERSION,
302            rk_version: env!("CARGO_PKG_VERSION").to_owned(),
303            payload_sha256: crate::commands::payload::report().payload_sha256,
304            origin: recorded.origin.clone(),
305            tech: recorded.tech.clone(),
306            forge: recorded.forge.clone(),
307            landed_at: recorded.landed_at.clone(),
308            parameters: manifest::Parameters {
309                repo: recorded.parameters.repo.clone(),
310                workflow: recorded.parameters.workflow,
311                style: recorded.parameters.style,
312                nix: recorded.parameters.nix,
313            },
314            files: decisions
315                .iter()
316                .map(|decision| clone_record(&decision.record))
317                .collect(),
318            pins: registry::pins_for(&recorded.tech)
319                .into_iter()
320                .map(|pin| (pin.name, pin.version))
321                .collect(),
322        },
323    )
324}
325
326/// The record an upgrade may act on: present, at a known schema, and not
327/// from a newer binary than this one.
328fn load_upgradable(target: &camino::Utf8Path) -> Result<Manifest, RkError> {
329    let Some(recorded) = manifest::load(target)? else {
330        return Err(RkError::refusal(
331            Diagnostic::new(
332                Reason::StateDrift,
333                format!(
334                    "no {} at {target}: there is no baseline to upgrade against",
335                    manifest::MANIFEST_PATH
336                ),
337            )
338            .expected("a recorded landing")
339            .action(
340                "rk init lands a first landing; rk adopt records one made before the record existed",
341            )
342            .target_state("unchanged"),
343        ));
344    };
345    if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
346        == Alignment::TargetNewer
347    {
348        return Err(RkError::refusal(
349            Diagnostic::new(
350                Reason::StateDrift,
351                format!(
352                    "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
353                    recorded.rk_version,
354                    env!("CARGO_PKG_VERSION")
355                ),
356            )
357            .expected("a binary at or above the recorded rk_version")
358            .action(format!("install release-kit {} or newer", recorded.rk_version))
359            .target_state("unchanged"),
360        ));
361    }
362    Ok(recorded)
363}
364
365/// Decide one candidate destination from the three digests.
366/// Every entry decided in one pass, with the collected conflicts. An
367/// ill-formed hook file is a conflict in preview and apply alike: its
368/// first block may match while a duplicate still executes, so the
369/// per-entry comparison cannot see it, and the refusal names each
370/// conflict once.
371fn decide_all<'a>(
372    args: &UpgradeArgs,
373    recorded: &'a Manifest,
374    entries: &'a [Entry],
375) -> Result<(Vec<Decision<'a>>, Vec<String>), RkError> {
376    let mut conflicts: Vec<String> = Vec::new();
377    let mut decisions: Vec<Decision<'a>> = Vec::new();
378    if landing::hooks_file_defect(&args.target)?.is_some() {
379        conflicts.push(landing::HOOKS_DESTINATION.to_owned());
380    }
381    for entry in entries {
382        let disk = landing::read_recorded(&args.target, &entry.destination)?;
383        let mut decision = decide(
384            entry,
385            recorded.file(&entry.destination),
386            disk.as_deref(),
387            &mut conflicts,
388        );
389        if entry.destination == landing::HOOKS_DESTINATION
390            && conflicts.iter().any(|c| c == landing::HOOKS_DESTINATION)
391        {
392            decision.action = "conflict";
393        }
394        decisions.push(decision);
395    }
396    let mut seen = std::collections::HashSet::new();
397    conflicts.retain(|conflict| seen.insert(conflict.clone()));
398    Ok((decisions, conflicts))
399}
400
401fn decide<'a>(
402    entry: &'a Entry,
403    recorded: Option<&FileRecord>,
404    disk: Option<&[u8]>,
405    conflicts: &mut Vec<String>,
406) -> Decision<'a> {
407    let candidate_record = |sha256: Digest| FileRecord {
408        destination: entry.destination.clone(),
409        kind: entry.kind,
410        sha256,
411        baseline_sha256: match entry.kind {
412            Kind::State => None,
413            Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
414        },
415    };
416    let Some(recorded) = recorded else {
417        return decide_added(entry, disk, conflicts);
418    };
419
420    // A seeded file this payload reclassifies as rendered claims
421    // ownership of a file the target may have tuned; only untouched bytes
422    // — matching the recorded baseline — permit the claim.
423    if recorded.kind == Kind::Seeded && entry.kind == Kind::Rendered {
424        let untouched =
425            disk.is_some_and(|bytes| Some(Digest::of(bytes)) == recorded.baseline_sha256);
426        if !untouched {
427            conflicts.push(entry.destination.clone());
428            return Decision {
429                entry: Some(entry),
430                action: "conflict",
431                record: candidate_record(Digest::of(&entry.rendered)),
432            };
433        }
434        return Decision {
435            entry: Some(entry),
436            action: "updated",
437            record: candidate_record(Digest::of(&entry.rendered)),
438        };
439    }
440
441    match entry.kind {
442        Kind::Rendered => match disk {
443            Some(bytes) if Digest::of(bytes) == recorded.sha256 => Decision {
444                entry: Some(entry),
445                action: if bytes == entry.rendered {
446                    "unchanged"
447                } else {
448                    "updated"
449                },
450                record: candidate_record(Digest::of(&entry.rendered)),
451            },
452            Some(bytes) if bytes == entry.rendered => Decision {
453                entry: Some(entry),
454                action: "unchanged",
455                record: candidate_record(Digest::of(&entry.rendered)),
456            },
457            // Edited or deleted: either way the target changed a file
458            // release-kit owns.
459            _ => {
460                conflicts.push(entry.destination.clone());
461                Decision {
462                    entry: Some(entry),
463                    action: "conflict",
464                    record: candidate_record(Digest::of(&entry.rendered)),
465                }
466            }
467        },
468        Kind::Seeded => {
469            // Never written; the record keeps the target's current bytes
470            // and the baseline it tunes away from. For a file this payload
471            // reclassifies from rendered to seeded — safe and silent — that
472            // baseline is the rendered bytes release-kit last wrote, not
473            // the pre-substitution payload, so an untouched file is not
474            // reported as drift.
475            let baseline = if recorded.kind == Kind::Rendered {
476                Some(recorded.sha256.clone())
477            } else {
478                recorded.baseline_sha256.clone()
479            };
480            let (action, sha256) = disk.map_or_else(
481                || ("drift", recorded.sha256.clone()),
482                |bytes| {
483                    let digest = Digest::of(bytes);
484                    if Some(&digest) == baseline.as_ref() {
485                        ("unchanged", digest)
486                    } else {
487                        ("drift", digest)
488                    }
489                },
490            );
491            Decision {
492                entry: None,
493                action,
494                record: FileRecord {
495                    destination: entry.destination.clone(),
496                    kind: entry.kind,
497                    sha256,
498                    baseline_sha256: baseline,
499                },
500            }
501        }
502        Kind::State => Decision {
503            entry: None,
504            action: "state",
505            record: FileRecord {
506                destination: entry.destination.clone(),
507                kind: entry.kind,
508                sha256: recorded.sha256.clone(),
509                baseline_sha256: None,
510            },
511        },
512    }
513}
514
515/// A destination the record does not name, added by this payload: it
516/// lands exactly as `rk init` lands it — a differing `rendered`
517/// destination is a conflict, a differing `seeded` or `state` one is the
518/// target's and is kept.
519fn decide_added<'a>(
520    entry: &'a Entry,
521    disk: Option<&[u8]>,
522    conflicts: &mut Vec<String>,
523) -> Decision<'a> {
524    let (action, sha256) = match disk {
525        None => ("added", Digest::of(&entry.rendered)),
526        Some(bytes) if bytes == entry.rendered => ("unchanged", Digest::of(bytes)),
527        Some(bytes) if entry.kind != Kind::Rendered => ("kept", Digest::of(bytes)),
528        Some(_) => {
529            conflicts.push(entry.destination.clone());
530            ("conflict", Digest::of(&entry.rendered))
531        }
532    };
533    Decision {
534        entry: Some(entry),
535        action,
536        record: FileRecord {
537            destination: entry.destination.clone(),
538            kind: entry.kind,
539            sha256,
540            baseline_sha256: match entry.kind {
541                Kind::State => None,
542                Kind::Rendered | Kind::Seeded => Some(Digest::of(&entry.baseline)),
543            },
544        },
545    }
546}
547
548/// A `rendered` destination that exists and is not a regular file refuses
549/// before anything is read.
550fn refuse_non_regular(target: &camino::Utf8Path, entries: &[Entry]) -> Result<(), RkError> {
551    for entry in entries {
552        if entry.kind != Kind::Rendered {
553            continue;
554        }
555        let path = target.join(&entry.destination);
556        if let Ok(meta) = std::fs::symlink_metadata(&path) {
557            if !meta.is_file() {
558                return Err(RkError::refusal(
559                    Diagnostic::new(
560                        Reason::StateDrift,
561                        format!("{path} exists and is not a regular file; nothing was written"),
562                    )
563                    .expected("every rendered destination a regular file")
564                    .target_state("unchanged"),
565                ));
566            }
567        }
568    }
569    Ok(())
570}
571
572/// The judgment sentinels a newly written file carries.
573fn collect_sentinels(entry: &Entry, found: &mut Vec<String>) {
574    let text = String::from_utf8_lossy(&entry.rendered);
575    for (idx, line) in text.lines().enumerate() {
576        if line.contains(embedded::SENTINEL) {
577            found.push(format!(
578                "{}:{}: {}",
579                entry.destination,
580                idx + 1,
581                line.trim()
582            ));
583        }
584    }
585}
586
587/// [`FileRecord`] carries digests, which are cheap to clone by field.
588fn clone_record(record: &FileRecord) -> FileRecord {
589    FileRecord {
590        destination: record.destination.clone(),
591        kind: record.kind,
592        sha256: record.sha256.clone(),
593        baseline_sha256: record.baseline_sha256.clone(),
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    #![allow(clippy::expect_used)]
600
601    use super::{FileEntry, Report};
602
603    /// The complete `rk.upgrade/3` shape, held by snapshot.
604    #[test]
605    fn the_upgrade_report_schema_snapshot_holds() {
606        let report = Report {
607            schema: "rk.upgrade/4",
608            mode: "preview",
609            target: "/tmp/t".into(),
610            tech: "rust".into(),
611            forge: "github".into(),
612            from_version: "0.1.0".into(),
613            to_version: "0.2.0",
614            workflow: "branches",
615            style: "trunk",
616            nix: false,
617            withheld: None,
618            files: vec![FileEntry {
619                path: "release-plz.toml".into(),
620                kind: "seeded",
621                action: "drift",
622            }],
623            next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
624        };
625        assert_eq!(
626            serde_json::to_string(&report).expect("a report serializes"),
627            r#"{"schema":"rk.upgrade/4","mode":"preview","target":"/tmp/t","tech":"rust","forge":"github","from_version":"0.1.0","to_version":"0.2.0","workflow":"branches","style":"trunk","nix":false,"files":[{"path":"release-plz.toml","kind":"seeded","action":"drift"}],"next":["rk upgrade --target /tmp/t --apply writes"]}"#
628        );
629    }
630}