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