Skip to main content

ossctl_core/release/
bump_exec.rs

1//! Cut-time execution of the sealed version-bump phase (`release-rust-workspace-
2//! multicrate` facet 2/3) — the effectful half whose pure text transforms live in
3//! [`crate::release::bump`].
4//!
5//! [`apply_bump`] runs **inside the coordinator's clean checkout of the sealed commit**
6//! (`release-cut-clean-checkout`), before the build barrier, so every later phase
7//! builds and publishes the bumped tree. It:
8//!
9//! 1. sets `[workspace.package] version` in the root manifest;
10//! 2. rewrites each sealed intra-workspace `=`-pin (verifying the exact old value in the
11//!    tree — fail closed on zero/multiple, [`crate::release::bump::rewrite_pin`]);
12//! 3. refreshes `Cargo.lock` (`cargo update --workspace`);
13//! 4. finalizes the CHANGELOG (`[Unreleased]` → a dated section) when the contract's
14//!    changelog mode asked for it;
15//! 5. runs any contract-declared `bump_hook` (see the execution contract below);
16//! 6. commits the edits and returns the **bump commit sha** — the commit the tag points
17//!    at (not the pre-bump sealed HEAD).
18//!
19//! Every step **fails closed**: a failed edit, lockfile refresh, hook, post-hook
20//! validation, or commit aborts the cut with a [`BumpExecError`] before the build
21//! barrier — nothing external has happened yet (no publish, no tag).
22//!
23//! # File I/O
24//!
25//! Like the [`homebrew`](crate::release::adapters::homebrew) adapter, this reads and
26//! writes manifests/CHANGELOG with `std::fs` directly (a manifest edit has no CLI to
27//! route through [`CommandRunner`](crate::ports::CommandRunner)); all writes are confined to the throwaway sealed
28//! checkout the coordinator just materialized. Process effects (`cargo`, the hook,
29//! `git`) go through the injected [`CommandRunner`](crate::ports::CommandRunner), so the executor is unit-testable
30//! against a real temp checkout + a recording fake runner.
31//!
32//! # `bump_hook` execution contract (supply-chain surface, schema.rs)
33//!
34//! A declared `bump_hook` is **arbitrary code the engine runs during the release**,
35//! equivalent in trust to a `build.rs` the cut already compiles. Its purpose is to
36//! regenerate version-embedding artifacts (test snapshots that embed the version) so
37//! they do not go stale on the bump and red CI. The contract this executor honors:
38//!
39//! - **Invocation:** `sh -c "<hook>"` with the hook string passed as a **single,
40//!   verbatim** argv element — **no** dynamic data (version, package names) is ever
41//!   interpolated into it, so there is no shell-injection surface from cut-time data
42//!   (schema.rs:399). The hook is surfaced verbatim as a plan-time reviewer warning, so
43//!   an approver has seen exactly what runs.
44//! - **Working directory:** the sealed checkout root.
45//! - **Environment:** the cut's ambient environment (inherited), the same trust
46//!   boundary as a `build.rs`. Secrets the cut carries (registry tokens) are reachable —
47//!   this is why the hook is an eyes-on supply-chain surface, not a sandbox.
48//! - **Permitted effect:** regenerating derived files. **Post-hook validation** re-reads
49//!   the workspace manifest and rejects the cut if the hook reverted or altered the
50//!   version bump ([`BumpExecError::HookViolatedVersion`]) — the one invariant that must
51//!   survive, since a hook that changed the version would publish the wrong number. A
52//!   full permitted-path allowlist is intentionally not enforced (snapshot regen writes
53//!   an open-ended set of test files); the version invariant is the load-bearing check.
54//! - **Failure:** a non-zero exit fails the cut closed ([`BumpExecError::Hook`]).
55//! - **Timeout:** the [`CommandRunner`](crate::ports::CommandRunner) port carries no timeout today, so the hook runs
56//!   to completion (a CI-level job timeout is the backstop). A first-class per-command
57//!   timeout is a tracked follow-up.
58
59use std::collections::BTreeMap;
60use std::path::{Path, PathBuf};
61
62use crate::protocol::plan::BumpPlan;
63use crate::release::adapters::EffectCtx;
64use crate::release::bump::{self, BumpEditError};
65
66/// The outcome of an applied bump: the commit the edits landed in (the tag target) and
67/// the CHANGELOG effective date (journalled for resume reuse).
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct BumpOutcome {
70    /// The bump commit sha — the commit the release tag points at.
71    pub commit: String,
72    /// The `YYYY-MM-DD` date the CHANGELOG was finalized under.
73    pub effective_date: String,
74}
75
76/// Why the cut-time bump could not be applied. Every variant is a fail-closed refusal
77/// **before** the build barrier — no publish or tag has happened.
78#[derive(Debug)]
79pub enum BumpExecError {
80    /// A pure edit transform refused (missing workspace version, a non-matching or
81    /// ambiguous pin, or an absent `## [Unreleased]` section).
82    Edit(BumpEditError),
83    /// A manifest/CHANGELOG file could not be read or written in the checkout.
84    Fs {
85        /// The offending path.
86        path: PathBuf,
87        /// The underlying I/O error.
88        source: std::io::Error,
89    },
90    /// A pin rewrite named a workspace member whose manifest could not be located in
91    /// the checkout (a `package` the workspace graph resolved but whose directory the
92    /// executor could not map). Fail closed rather than skip a sealed pin.
93    MemberManifestNotFound {
94        /// The dependent crate whose manifest was expected.
95        package: String,
96    },
97    /// `cargo update --workspace` (the lockfile refresh) failed.
98    LockRefresh(String),
99    /// The declared `bump_hook` exited non-zero.
100    Hook {
101        /// The exit code (or a signal note).
102        status: String,
103        /// Captured stderr, trimmed.
104        stderr: String,
105    },
106    /// The `bump_hook` altered the workspace version away from the bumped value — a
107    /// contract violation (the hook may regenerate derived artifacts, not re-version).
108    HookViolatedVersion {
109        /// The version the bump set.
110        expected: String,
111        /// What the manifest read after the hook.
112        found: String,
113    },
114    /// A `git` step (add / commit / rev-parse / push) failed.
115    Git(String),
116}
117
118impl std::fmt::Display for BumpExecError {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        match self {
121            Self::Edit(e) => write!(f, "{e}"),
122            Self::Fs { path, source } => {
123                write!(
124                    f,
125                    "cannot access `{}` in the sealed checkout: {source}",
126                    path.display()
127                )
128            }
129            Self::MemberManifestNotFound { package } => write!(
130                f,
131                "the bump names crate `{package}` but its manifest could not be located in the \
132                 sealed checkout"
133            ),
134            Self::LockRefresh(m) => write!(f, "refreshing Cargo.lock failed: {m}"),
135            Self::Hook { status, stderr } => {
136                write!(f, "the bump_hook failed ({status}): {stderr}")
137            }
138            Self::HookViolatedVersion { expected, found } => write!(
139                f,
140                "the bump_hook changed the workspace version to `{found}`, but the bump set \
141                 `{expected}` — refusing to publish a hook-altered version"
142            ),
143            Self::Git(m) => write!(f, "git step failed during the bump: {m}"),
144        }
145    }
146}
147
148impl std::error::Error for BumpExecError {}
149
150impl From<BumpEditError> for BumpExecError {
151    fn from(e: BumpEditError) -> Self {
152        Self::Edit(e)
153    }
154}
155
156/// Apply the sealed `bump` inside `ctx.repo_root` (the coordinator's clean checkout),
157/// committing the edits and returning the [`BumpOutcome`]. `effective_date` is the
158/// `YYYY-MM-DD` CHANGELOG date (freshly computed on a first run, or the journalled date
159/// on resume so it never re-dates).
160///
161/// The bump commit is created on the checkout's detached HEAD; its object lives in the
162/// shared store, so the coordinator's tagger (running against the real repo) can point
163/// the release tag at it within the same cut. Advancing the real repo's *branch* to the
164/// bump commit is deliberately **not** done here (a best-effort pre-publish push would be
165/// an externally-visible effect before the build barrier, and a failed one leaves a
166/// split-brain remote) — it is a documented follow-up (`release-rust-workspace-multicrate`).
167///
168/// # Errors
169/// [`BumpExecError`] on any failed edit, lockfile refresh, hook, post-hook validation,
170/// or git step — always **before** the build barrier, so nothing external happened.
171pub fn apply_bump(
172    ctx: &EffectCtx<'_>,
173    bump: &BumpPlan,
174    effective_date: &str,
175) -> Result<BumpOutcome, BumpExecError> {
176    let root = ctx.repo_root;
177
178    // 1. Set the workspace version in the root manifest (verified against `from_version`).
179    let root_manifest = root.join("Cargo.toml");
180    let text = read(&root_manifest)?;
181    let bumped = bump::set_workspace_version(&text, &bump.from_version, &bump.to_version)?;
182    write(&root_manifest, &bumped)?;
183
184    // 2. Rewrite each sealed intra-workspace pin in its dependent crate's manifest,
185    //    verifying the exact old value (fail closed on zero/multiple).
186    if !bump.pin_rewrites.is_empty() {
187        let members = member_manifest_paths(root)?;
188        for pin in &bump.pin_rewrites {
189            let manifest = members.get(&pin.in_package).ok_or_else(|| {
190                BumpExecError::MemberManifestNotFound {
191                    package: pin.in_package.clone(),
192                }
193            })?;
194            let text = read(manifest)?;
195            let rewritten = bump::rewrite_pin(&text, &pin.dependency, &pin.from, &pin.to)?;
196            write(manifest, &rewritten)?;
197        }
198    }
199
200    // 3. Refresh Cargo.lock so its workspace-member entries carry the new version — but
201    //    only when the repo tracks a lockfile (a library workspace that intentionally
202    //    git-ignores Cargo.lock must not have one introduced by the cut). NOTE: this runs
203    //    `cargo update --workspace`, which re-resolves within the manifests' semver ranges;
204    //    for a version-only bump the third-party graph is unchanged in practice, but
205    //    pinning the resolution deterministically is a documented follow-up.
206    if root.join("Cargo.lock").exists() {
207        refresh_lockfile(ctx)?;
208    }
209
210    // 4. Finalize the CHANGELOG when the contract's mode asked for it and a CHANGELOG
211    //    exists. A declared-but-missing `## [Unreleased]` fails closed (via the transform).
212    if bump.changelog_finalize {
213        let changelog = root.join("CHANGELOG.md");
214        if changelog.exists() {
215            let text = read(&changelog)?;
216            let finalized = bump::finalize_changelog(&text, &bump.to_version, effective_date)?;
217            write(&changelog, &finalized)?;
218        }
219    }
220
221    // 5. Run the declared bump_hook (supply-chain surface — see the module docs), then
222    //    validate the hook did not alter the version.
223    if let Some(hook) = &bump.bump_hook {
224        run_hook(ctx, hook)?;
225        let after = read(&root_manifest)?;
226        let found = bump::workspace_version(&after);
227        if found.as_deref() != Some(bump.to_version.as_str()) {
228            return Err(BumpExecError::HookViolatedVersion {
229                expected: bump.to_version.clone(),
230                found: found.unwrap_or_default(),
231            });
232        }
233    }
234
235    // 6. Commit the edits in the checkout and read back the bump commit sha.
236    let commit = commit_bump(ctx, &bump.to_version)?;
237
238    Ok(BumpOutcome {
239        commit,
240        effective_date: effective_date.to_string(),
241    })
242}
243
244/// Read a checkout file, mapping I/O errors to [`BumpExecError::Fs`].
245fn read(path: &Path) -> Result<String, BumpExecError> {
246    std::fs::read_to_string(path).map_err(|source| BumpExecError::Fs {
247        path: path.to_path_buf(),
248        source,
249    })
250}
251
252/// Write a checkout file, mapping I/O errors to [`BumpExecError::Fs`].
253fn write(path: &Path, contents: &str) -> Result<(), BumpExecError> {
254    std::fs::write(path, contents).map_err(|source| BumpExecError::Fs {
255        path: path.to_path_buf(),
256        source,
257    })
258}
259
260/// Refresh `Cargo.lock`'s workspace-member entries to the bumped version via
261/// `cargo update --workspace` (dependencies untouched).
262fn refresh_lockfile(ctx: &EffectCtx<'_>) -> Result<(), BumpExecError> {
263    let out = ctx
264        .runner
265        .run("cargo", &["update", "--workspace"], ctx.repo_root)
266        .map_err(|e| BumpExecError::LockRefresh(format!("cannot run cargo: {e}")))?;
267    if out.status != Some(0) {
268        return Err(BumpExecError::LockRefresh(format!(
269            "exit {}: {}",
270            status_str(out.status),
271            out.stderr.trim()
272        )));
273    }
274    Ok(())
275}
276
277/// Run the contract-declared `bump_hook` as `sh -c "<hook>"` in the checkout — the
278/// verbatim string as a single argv element, no interpolation (see the module docs).
279fn run_hook(ctx: &EffectCtx<'_>, hook: &str) -> Result<(), BumpExecError> {
280    let out = ctx
281        .runner
282        .run("sh", &["-c", hook], ctx.repo_root)
283        .map_err(|e| BumpExecError::Hook {
284            status: "spawn failed".to_string(),
285            stderr: e.to_string(),
286        })?;
287    if out.status != Some(0) {
288        return Err(BumpExecError::Hook {
289            status: status_str(out.status),
290            stderr: out.stderr.trim().to_string(),
291        });
292    }
293    Ok(())
294}
295
296/// `git add -A` then `git commit` the bump edits in the checkout, returning the new
297/// commit sha (`git rev-parse HEAD`).
298fn commit_bump(ctx: &EffectCtx<'_>, version: &str) -> Result<String, BumpExecError> {
299    let root = ctx.repo_root;
300    run_git(ctx, &["add", "-A"], root)?;
301    let message = format!("release: v{version}");
302    run_git(ctx, &["commit", "-m", &message], root)?;
303    let out = ctx
304        .runner
305        .run("git", &["rev-parse", "HEAD"], root)
306        .map_err(|e| BumpExecError::Git(format!("rev-parse HEAD: {e}")))?;
307    if out.status != Some(0) {
308        return Err(BumpExecError::Git(format!(
309            "rev-parse HEAD exit {}: {}",
310            status_str(out.status),
311            out.stderr.trim()
312        )));
313    }
314    let sha = out.stdout.trim().to_string();
315    if sha.is_empty() {
316        return Err(BumpExecError::Git(
317            "git rev-parse HEAD returned no commit sha after the bump commit".to_string(),
318        ));
319    }
320    Ok(sha)
321}
322
323/// Run a `git` subcommand in `cwd`, failing closed on a non-zero exit.
324fn run_git(ctx: &EffectCtx<'_>, args: &[&str], cwd: &Path) -> Result<(), BumpExecError> {
325    let out = ctx
326        .runner
327        .run("git", args, cwd)
328        .map_err(|e| BumpExecError::Git(format!("`git {}`: {e}", args.join(" "))))?;
329    if out.status != Some(0) {
330        return Err(BumpExecError::Git(format!(
331            "`git {}` exit {}: {}",
332            args.join(" "),
333            status_str(out.status),
334            out.stderr.trim()
335        )));
336    }
337    Ok(())
338}
339
340/// Map member crate names to their manifest paths by scanning the workspace root's
341/// `[workspace] members` (explicit paths + trailing single-level globs), reading each
342/// `[package].name`. Mirrors the facts detector's member resolution enough for the
343/// lib+bin shape; an unresolved dependent fails the pin rewrite closed.
344fn member_manifest_paths(root: &Path) -> Result<BTreeMap<String, PathBuf>, BumpExecError> {
345    let root_manifest = root.join("Cargo.toml");
346    let text = read(&root_manifest)?;
347    let mut map = BTreeMap::new();
348    for rel in workspace_member_dirs(root, &text) {
349        let manifest = root.join(&rel).join("Cargo.toml");
350        let Ok(member_text) = std::fs::read_to_string(&manifest) else {
351            continue;
352        };
353        if let Some(name) = package_name(&member_text) {
354            map.insert(name, manifest);
355        }
356    }
357    Ok(map)
358}
359
360/// The workspace member directories declared in a root manifest's `[workspace] members`
361/// array — explicit entries plus a trailing single-level glob (`crates/*`) expanded by
362/// listing that directory. A best-effort line scan matching the facts detector's shape.
363fn workspace_member_dirs(root: &Path, root_text: &str) -> Vec<String> {
364    let Some(members) = toml_string_array(root_text, "members") else {
365        return Vec::new();
366    };
367    let mut dirs = Vec::new();
368    for entry in members {
369        if let Some(parent) = entry.strip_suffix("/*") {
370            // Expand a single-level glob by listing the parent directory.
371            if let Ok(read_dir) = std::fs::read_dir(root.join(parent)) {
372                for e in read_dir.flatten() {
373                    if e.path().is_dir() {
374                        dirs.push(format!("{parent}/{}", e.file_name().to_string_lossy()));
375                    }
376                }
377            }
378        } else if !entry.contains('*') {
379            dirs.push(entry);
380        }
381    }
382    dirs
383}
384
385/// The `members = ["…", …]` string array under a `[workspace]` table, as owned strings.
386/// A best-effort single-array scan (members are declared once, near the top).
387fn toml_string_array(text: &str, key: &str) -> Option<Vec<String>> {
388    // Find `<key> = [` and read until the closing `]` (possibly multi-line).
389    let mut in_workspace = false;
390    let mut collecting = false;
391    let mut buf = String::new();
392    for line in text.lines() {
393        let t = line.trim();
394        if let Some(h) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
395            in_workspace = h.trim() == "workspace";
396            continue;
397        }
398        if collecting {
399            buf.push_str(line);
400            if line.contains(']') {
401                break;
402            }
403            continue;
404        }
405        if in_workspace {
406            if let Some(rest) = strip_key(t, key) {
407                if let Some(after) = rest.trim_start().strip_prefix('[') {
408                    buf.push_str(after);
409                    if t.contains(']') {
410                        break;
411                    }
412                    collecting = true;
413                }
414            }
415        }
416    }
417    if buf.is_empty() && !collecting {
418        return None;
419    }
420    let inner = buf.split(']').next().unwrap_or("");
421    let items: Vec<String> = inner
422        .split(',')
423        .filter_map(|s| {
424            let s = s.trim().trim_matches(['"', '\'']);
425            (!s.is_empty()).then(|| s.to_string())
426        })
427        .collect();
428    Some(items)
429}
430
431/// If `line` is `key = <rest>` (whole key), return `<rest>`; else `None`.
432fn strip_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
433    let rest = line.strip_prefix(key)?;
434    let rest = rest.trim_start();
435    rest.strip_prefix('=')
436}
437
438/// The `[package].name` of a member manifest, or `None`.
439fn package_name(text: &str) -> Option<String> {
440    let mut in_package = false;
441    for line in text.lines() {
442        let t = line.trim();
443        if let Some(h) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
444            in_package = h.trim() == "package";
445            continue;
446        }
447        if in_package {
448            if let Some(rest) = strip_key(t, "name") {
449                return Some(rest.trim().trim_matches(['"', '\'']).to_string());
450            }
451        }
452    }
453    None
454}
455
456/// A subprocess status rendered for an error message.
457fn status_str(status: Option<i32>) -> String {
458    status.map_or_else(|| "signal".to_string(), |c| c.to_string())
459}
460
461/// The UTC `YYYY-MM-DD` civil date for a Unix timestamp — the CHANGELOG effective date
462/// the bump finalizes under. A self-contained `days → (y, m, d)` conversion (Howard
463/// Hinnant's `civil_from_days`) so the release path needs no chrono dependency; the
464/// injected [`Clock`](crate::ports::Clock) supplies the timestamp, so it is deterministic
465/// under test.
466#[must_use]
467pub fn civil_date(unix_secs: u64) -> String {
468    let days = i64::try_from(unix_secs / 86_400).unwrap_or(i64::MAX);
469    // Shift the epoch to 0000-03-01 and compute the year-of-era / day-of-year.
470    let z = days + 719_468;
471    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
472    let doe = z - era * 146_097; // [0, 146096]
473    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
474    let y = yoe + era * 400;
475    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
476    let mp = (5 * doy + 2) / 153; // [0, 11]
477    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
478    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
479    let y = if m <= 2 { y + 1 } else { y };
480    format!("{y:04}-{m:02}-{d:02}")
481}
482
483#[cfg(test)]
484mod tests;