feat(rehearse): engine::run_rehearsal + shipper rehearse CLI (#97 PR 2)#127
Conversation
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 40 minutes and 2 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: ⛔ Files ignored due to path filters (24)
📒 Files selected for processing (8)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request implements the rehearse command, enabling a pre-publish verification step against alternate registries. The implementation includes the run_rehearsal engine, new event types for auditing, and CLI integration. Feedback suggests simplifying the CLI failure output to remove redundant information and adding a test case for the scenario where a package is published but fails the visibility check.
| println!( | ||
| "rehearsal FAILED after {}/{} packages against '{}': {}", | ||
| outcome.packages_published, | ||
| outcome.packages_attempted, | ||
| outcome.registry_name, | ||
| outcome.summary | ||
| ); |
There was a problem hiding this comment.
The output message for a failed rehearsal is a bit confusing. The outcome.summary field already contains a well-formatted message like "rehearsal stopped at 3/5: ...", which includes the total number of packages. The current println! adds after {}/{} packages which is redundant and can be misleading, as outcome.packages_attempted is not the total number of packages in the plan.
For example, if the 3rd package out of 5 fails, the output would be something like:
rehearsal FAILED after 2/3 packages against 'my-reg': rehearsal stopped at 3/5: ...
This is confusing. Consider simplifying the output to rely on the summary, which is already comprehensive.
| println!( | |
| "rehearsal FAILED after {}/{} packages against '{}': {}", | |
| outcome.packages_published, | |
| outcome.packages_attempted, | |
| outcome.registry_name, | |
| outcome.summary | |
| ); | |
| println!( | |
| "rehearsal FAILED against '{}': {}", | |
| outcome.registry_name, | |
| outcome.summary | |
| ); |
| #[test] | ||
| #[serial] | ||
| fn run_rehearsal_happy_path_emits_started_published_complete_events() { |
There was a problem hiding this comment.
The tests for run_rehearsal cover the happy path and a cargo publish failure, which is great. However, there's another important failure path that isn't covered: when cargo publish succeeds but the post-publish visibility check (version_exists) fails.
This can happen in real-world scenarios with registry propagation delays. Adding a test for this case would improve the robustness of the test suite.
The test would involve:
- Mocking
cargo::cargo_publishto return a successful exit code. - Mocking the registry server to return a 404 for the
version_existscheck. - Asserting that
run_rehearsalreturns aRehearsalOutcomewithpassed: false. - Asserting that a
RehearsalPackageFailedevent is emitted withErrorClass::Ambiguous.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c445abc07
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .registries | ||
| .iter() | ||
| .find(|r| r.name == rehearsal_name) | ||
| .cloned() |
There was a problem hiding this comment.
Resolve rehearsal registry from configured list by default
run_rehearsal only searches opts.registries for the rehearsal target, but build_runtime_options leaves opts.registries empty unless the operator also passes --registries or --all-registries (see crates/shipper-config/src/lib.rs around lines 815-857). This makes the documented [rehearsal] registry = "..." config path fail with “not configured” even when [[registries]] contains that name, so shipper rehearse is unusable in the default config-only flow.
Useful? React with 👍 / 👎.
|
|
||
| // Post-publish visibility check on the rehearsal registry. Reuse | ||
| // `version_exists` — same mechanism live publish trusts. | ||
| if !rehearsal_client.version_exists(&p.name, &p.version)? { |
There was a problem hiding this comment.
Reuse readiness backoff for rehearsal visibility checks
This check performs a single version_exists call immediately after cargo publish and fails rehearsal if it returns false, but registry visibility is often eventually consistent. The normal publish path uses verify_published with is_version_visible_with_backoff to avoid these false negatives; without that retry window, rehearsals can fail spuriously in CI even when the package becomes visible seconds later.
Useful? React with 👍 / 👎.
… PR 3) Close the rehearsal loop. PR 2 gave us \`shipper rehearse\` that publishes to an alt registry and emits RehearsalComplete. This PR makes live publish refuse to run unless a fresh, passing rehearsal receipt exists for the current plan_id. ## What's new - **\`state::rehearsal\`** module: a \`rehearsal.json\` sidecar next to \`state.json\` / \`events.jsonl\`. Schema versioned (\`shipper.rehearsal.v1\`), atomic write (\`.tmp\` + rename + fsync), crash-safe. Small enough to be grep-readable. - **Event schema additive**: \`RehearsalStarted\` and \`RehearsalComplete\` now carry \`plan_id\`. The events.jsonl audit trail now has enough information to replay the gate's decision after the fact. This is additive on variants that only shipped with PR 2 (#127), so no migration needed. - **\`engine::enforce_rehearsal_gate\`** + call site at the top of \`run_publish\`. Decision tree: 1. \`opts.rehearsal_registry = None\` → dormant, publish proceeds. Unchanged behavior for every caller who hasn't opted in. 2. \`opts.rehearsal_skip = true\` → proceed with loud warning. No fake-passing receipt is synthesized; auditors see "no rehearsal ran for this plan_id" in events.jsonl. 3. No \`rehearsal.json\` → refuse with fix-suggesting error. 4. Receipt's \`plan_id\` != current plan_id → refuse (stale). 5. \`passed: false\` → refuse with reason. 6. Fresh passing receipt → proceed, log a confirmation info line. - **\`run_rehearsal\` persists the sidecar** on completion. Best-effort: if the sidecar write fails, the events log is still authoritative and the gate will (correctly) refuse, blocking publish — the safe default. ## Tests 13 new tests. All green locally, clippy clean, fmt clean: - 3 roundtrip/persistence tests in \`state::rehearsal\` - 6 gate-helper tests (dormant / skip / missing / stale / failing / fresh-pass) - 1 e2e test that \`run_publish\` actually calls the gate and bails when rehearsal is required but missing Total rehearsal+gate coverage: 17 unit tests. ## Scope — not in this PR - **Install / smoke check** against the rehearsal registry (PR 4 or follow-on). The current gate trusts the rehearsal's publish + visibility verification. - **CI recipe** for kellnr or equivalent sidecar (separate PR; docs work, not code).
… PR 3) Close the rehearsal loop. PR 2 gave us \`shipper rehearse\` that publishes to an alt registry and emits RehearsalComplete. This PR makes live publish refuse to run unless a fresh, passing rehearsal receipt exists for the current plan_id. ## What's new - **\`state::rehearsal\`** module: a \`rehearsal.json\` sidecar next to \`state.json\` / \`events.jsonl\`. Schema versioned (\`shipper.rehearsal.v1\`), atomic write (\`.tmp\` + rename + fsync), crash-safe. Small enough to be grep-readable. - **Event schema additive**: \`RehearsalStarted\` and \`RehearsalComplete\` now carry \`plan_id\`. The events.jsonl audit trail now has enough information to replay the gate's decision after the fact. This is additive on variants that only shipped with PR 2 (#127), so no migration needed. - **\`engine::enforce_rehearsal_gate\`** + call site at the top of \`run_publish\`. Decision tree: 1. \`opts.rehearsal_registry = None\` → dormant, publish proceeds. Unchanged behavior for every caller who hasn't opted in. 2. \`opts.rehearsal_skip = true\` → proceed with loud warning. No fake-passing receipt is synthesized; auditors see "no rehearsal ran for this plan_id" in events.jsonl. 3. No \`rehearsal.json\` → refuse with fix-suggesting error. 4. Receipt's \`plan_id\` != current plan_id → refuse (stale). 5. \`passed: false\` → refuse with reason. 6. Fresh passing receipt → proceed, log a confirmation info line. - **\`run_rehearsal\` persists the sidecar** on completion. Best-effort: if the sidecar write fails, the events log is still authoritative and the gate will (correctly) refuse, blocking publish — the safe default. ## Tests 13 new tests. All green locally, clippy clean, fmt clean: - 3 roundtrip/persistence tests in \`state::rehearsal\` - 6 gate-helper tests (dormant / skip / missing / stale / failing / fresh-pass) - 1 e2e test that \`run_publish\` actually calls the gate and bails when rehearsal is required but missing Total rehearsal+gate coverage: 17 unit tests. ## Scope — not in this PR - **Install / smoke check** against the rehearsal registry (PR 4 or follow-on). The current gate trusts the rehearsal's publish + visibility verification. - **CI recipe** for kellnr or equivalent sidecar (separate PR; docs work, not code).
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Phase-2 preflight execution. PR 1 (#120) added config + CLI flag plumbing for rehearsal registry; this PR makes the flags actually DO something — publish every crate in the plan to an alternate registry, verify visibility, and emit a RehearsalComplete event so the outcome is auditable from events.jsonl alone. - EventType variants: RehearsalStarted / RehearsalPackagePublished / RehearsalPackageFailed / RehearsalComplete - RuntimeOptions fields: rehearsal_registry, rehearsal_skip - ShipperConfig::build_runtime_options populates with CLI > config > None precedence - engine::run_rehearsal: sequential, stops at first failure, emits events, does not touch state.json (pre-publish proof, not execution), verifies post-publish visibility via the same mechanism live publish uses, refuses to rehearse against the live target registry - shipper rehearse CLI subcommand Also regenerates the matching CLI snapshots (help_text, help_root, no_subcommand_error).
827ca17 to
87c5604
Compare
… PR 3) Close the rehearsal loop. PR 2 gave us \`shipper rehearse\` that publishes to an alt registry and emits RehearsalComplete. This PR makes live publish refuse to run unless a fresh, passing rehearsal receipt exists for the current plan_id. ## What's new - **\`state::rehearsal\`** module: a \`rehearsal.json\` sidecar next to \`state.json\` / \`events.jsonl\`. Schema versioned (\`shipper.rehearsal.v1\`), atomic write (\`.tmp\` + rename + fsync), crash-safe. Small enough to be grep-readable. - **Event schema additive**: \`RehearsalStarted\` and \`RehearsalComplete\` now carry \`plan_id\`. The events.jsonl audit trail now has enough information to replay the gate's decision after the fact. This is additive on variants that only shipped with PR 2 (#127), so no migration needed. - **\`engine::enforce_rehearsal_gate\`** + call site at the top of \`run_publish\`. Decision tree: 1. \`opts.rehearsal_registry = None\` → dormant, publish proceeds. Unchanged behavior for every caller who hasn't opted in. 2. \`opts.rehearsal_skip = true\` → proceed with loud warning. No fake-passing receipt is synthesized; auditors see "no rehearsal ran for this plan_id" in events.jsonl. 3. No \`rehearsal.json\` → refuse with fix-suggesting error. 4. Receipt's \`plan_id\` != current plan_id → refuse (stale). 5. \`passed: false\` → refuse with reason. 6. Fresh passing receipt → proceed, log a confirmation info line. - **\`run_rehearsal\` persists the sidecar** on completion. Best-effort: if the sidecar write fails, the events log is still authoritative and the gate will (correctly) refuse, blocking publish — the safe default. ## Tests 13 new tests. All green locally, clippy clean, fmt clean: - 3 roundtrip/persistence tests in \`state::rehearsal\` - 6 gate-helper tests (dormant / skip / missing / stale / failing / fresh-pass) - 1 e2e test that \`run_publish\` actually calls the gate and bails when rehearsal is required but missing Total rehearsal+gate coverage: 17 unit tests. ## Scope — not in this PR - **Install / smoke check** against the rehearsal registry (PR 4 or follow-on). The current gate trusts the rehearsal's publish + visibility verification. - **CI recipe** for kellnr or equivalent sidecar (separate PR; docs work, not code).
… PR 3) Close the rehearsal loop. PR 2 gave us \`shipper rehearse\` that publishes to an alt registry and emits RehearsalComplete. This PR makes live publish refuse to run unless a fresh, passing rehearsal receipt exists for the current plan_id. ## What's new - **\`state::rehearsal\`** module: a \`rehearsal.json\` sidecar next to \`state.json\` / \`events.jsonl\`. Schema versioned (\`shipper.rehearsal.v1\`), atomic write (\`.tmp\` + rename + fsync), crash-safe. Small enough to be grep-readable. - **Event schema additive**: \`RehearsalStarted\` and \`RehearsalComplete\` now carry \`plan_id\`. The events.jsonl audit trail now has enough information to replay the gate's decision after the fact. This is additive on variants that only shipped with PR 2 (#127), so no migration needed. - **\`engine::enforce_rehearsal_gate\`** + call site at the top of \`run_publish\`. Decision tree: 1. \`opts.rehearsal_registry = None\` → dormant, publish proceeds. Unchanged behavior for every caller who hasn't opted in. 2. \`opts.rehearsal_skip = true\` → proceed with loud warning. No fake-passing receipt is synthesized; auditors see "no rehearsal ran for this plan_id" in events.jsonl. 3. No \`rehearsal.json\` → refuse with fix-suggesting error. 4. Receipt's \`plan_id\` != current plan_id → refuse (stale). 5. \`passed: false\` → refuse with reason. 6. Fresh passing receipt → proceed, log a confirmation info line. - **\`run_rehearsal\` persists the sidecar** on completion. Best-effort: if the sidecar write fails, the events log is still authoritative and the gate will (correctly) refuse, blocking publish — the safe default. ## Tests 13 new tests. All green locally, clippy clean, fmt clean: - 3 roundtrip/persistence tests in \`state::rehearsal\` - 6 gate-helper tests (dormant / skip / missing / stale / failing / fresh-pass) - 1 e2e test that \`run_publish\` actually calls the gate and bails when rehearsal is required but missing Total rehearsal+gate coverage: 17 unit tests. ## Scope — not in this PR - **Install / smoke check** against the rehearsal registry (PR 4 or follow-on). The current gate trusts the rehearsal's publish + visibility verification. - **CI recipe** for kellnr or equivalent sidecar (separate PR; docs work, not code).
… PR 3) (#133) * feat(rehearse): hard gate — run_publish requires passing rehearsal (#97 PR 3) Close the rehearsal loop. PR 2 gave us \`shipper rehearse\` that publishes to an alt registry and emits RehearsalComplete. This PR makes live publish refuse to run unless a fresh, passing rehearsal receipt exists for the current plan_id. ## What's new - **\`state::rehearsal\`** module: a \`rehearsal.json\` sidecar next to \`state.json\` / \`events.jsonl\`. Schema versioned (\`shipper.rehearsal.v1\`), atomic write (\`.tmp\` + rename + fsync), crash-safe. Small enough to be grep-readable. - **Event schema additive**: \`RehearsalStarted\` and \`RehearsalComplete\` now carry \`plan_id\`. The events.jsonl audit trail now has enough information to replay the gate's decision after the fact. This is additive on variants that only shipped with PR 2 (#127), so no migration needed. - **\`engine::enforce_rehearsal_gate\`** + call site at the top of \`run_publish\`. Decision tree: 1. \`opts.rehearsal_registry = None\` → dormant, publish proceeds. Unchanged behavior for every caller who hasn't opted in. 2. \`opts.rehearsal_skip = true\` → proceed with loud warning. No fake-passing receipt is synthesized; auditors see "no rehearsal ran for this plan_id" in events.jsonl. 3. No \`rehearsal.json\` → refuse with fix-suggesting error. 4. Receipt's \`plan_id\` != current plan_id → refuse (stale). 5. \`passed: false\` → refuse with reason. 6. Fresh passing receipt → proceed, log a confirmation info line. - **\`run_rehearsal\` persists the sidecar** on completion. Best-effort: if the sidecar write fails, the events log is still authoritative and the gate will (correctly) refuse, blocking publish — the safe default. ## Tests 13 new tests. All green locally, clippy clean, fmt clean: - 3 roundtrip/persistence tests in \`state::rehearsal\` - 6 gate-helper tests (dormant / skip / missing / stale / failing / fresh-pass) - 1 e2e test that \`run_publish\` actually calls the gate and bails when rehearsal is required but missing Total rehearsal+gate coverage: 17 unit tests. ## Scope — not in this PR - **Install / smoke check** against the rehearsal registry (PR 4 or follow-on). The current gate trusts the rehearsal's publish + visibility verification. - **CI recipe** for kellnr or equivalent sidecar (separate PR; docs work, not code). * docs: how-to for rehearsing against an alternate registry (#97) Stitch the Prove pillar tier 2 together with a step-by-step operator guide that walks through rehearsal-registry configuration, shipper rehearse, the hard gate, and a kellnr-sidecar CI recipe. Covers: - When to use rehearsal (first-publish, post-refactor) - Config via .shipper.toml or CLI flag - Event emissions + rehearsal.json sidecar - Hard-gate decision tree - Complete GitHub Actions example with kellnr sidecar - Troubleshooting for the 3 common gate errors - What rehearsal does NOT cover yet (install/smoke, consumer-build) Tied to docs/README.md so operators can find it. No code changes.
Three-column reconciliation of what's actually merged vs each issue's acceptance checklist. Single source of truth for what closes each pillar issue. **Findings:** - **#90 Recover** — honestly closable. Code side is done (#124 + #130); operator-side real rehearsal is an ops action, not a code gap. - **#97 Prove tier 2** — 85% done. Rehearsal + visibility + hard gate + plan_id binding all landed (#127 + #133). Missing: install/smoke check (cargo install against the rehearsal registry / consumer build). One narrow follow-up PR closes it. - **#98 Remediate** — 60% done. Receipt schema + plan-yank (from-receipt) + yank primitive + fix-forward planning all landed (#121 + #132 + #134). Missing: plan-yank's --starting-crate graph mode, plan execution for yank + fix-forward. Two narrow follow-ups. Also captures the two review concerns on #122 (Trusted Publishing) that were addressed in a follow-up commit to that PR. Recommended next merge order and follow-up PRs spelled out at bottom.
Adds the final piece of #97's acceptance: \`cargo install --registry <rehearsal> <crate>\` after rehearsal publishes succeed, as end-to-end proof that the crate actually resolves and installs via the registry index — the scenario that workspace-path dependencies defeat and that killed the rc.1 first-publish. ## What's new - **EventType variants**: \`RehearsalSmokeCheckStarted\`, \`RehearsalSmokeCheckSucceeded\`, \`RehearsalSmokeCheckFailed\`. - **RuntimeOptions**: \`rehearsal_smoke_install: Option<String>\`. - **CliOverrides + CLI flag**: \`--smoke-install <CRATE>\` (global). Crate must be in the rehearsal plan and have a \`[[bin]]\` target; library-only crates can't be smoke-installed directly (consumer- workspace build variant is a follow-on). - **\`ops::cargo::cargo_install_smoke\`**: wrapper around \`cargo install --registry <name> <crate> --version <v> --root <tmp> --force --locked\`. - **engine::run_rehearsal** post-publish step: if all packages passed AND \`--smoke-install\` names an in-plan crate, install it from the rehearsal registry. Install failure fails the whole rehearsal (sets \`first_failure\` + \`RehearsalComplete { passed: false }\`). Named-but-not-in-plan: warn only; don't fail rehearsal over a typo. ## Tests - \`run_rehearsal_smoke_install_happy_path_emits_succeeded_event\` — flag named an in-plan crate, fake cargo returns 0, assert \`rehearsal_smoke_check_started\` + \`_succeeded\` events. - \`run_rehearsal_smoke_install_missing_target_warns_without_failing\` — flag named a crate not in the plan, assert reporter warn + rehearsal still passes. 19 rehearsal+gate tests pass (up from 17). ## Closes #97 With this PR, #97's issue checklist is 100% fulfilled: - [x] Publish packaged tarballs to alt registry (#127) - [x] Verify presence on rehearsal registry (#127) - [x] Install/build from rehearsal registry (**this PR**) - [x] Record proof in RehearsalComplete event (#127) - [x] Hard gate binding on plan_id (#133) The \`#97\` issue can be closed once this merges.
Three-column reconciliation of what's actually merged vs each issue's acceptance checklist. Single source of truth for what closes each pillar issue. **Findings:** - **#90 Recover** — honestly closable. Code side is done (#124 + #130); operator-side real rehearsal is an ops action, not a code gap. - **#97 Prove tier 2** — 85% done. Rehearsal + visibility + hard gate + plan_id binding all landed (#127 + #133). Missing: install/smoke check (cargo install against the rehearsal registry / consumer build). One narrow follow-up PR closes it. - **#98 Remediate** — 60% done. Receipt schema + plan-yank (from-receipt) + yank primitive + fix-forward planning all landed (#121 + #132 + #134). Missing: plan-yank's --starting-crate graph mode, plan execution for yank + fix-forward. Two narrow follow-ups. Also captures the two review concerns on #122 (Trusted Publishing) that were addressed in a follow-up commit to that PR. Recommended next merge order and follow-up PRs spelled out at bottom.
* feat(rehearse): smoke-install check closes #97 Prove tier 2 (#97 PR 4) Adds the final piece of #97's acceptance: \`cargo install --registry <rehearsal> <crate>\` after rehearsal publishes succeed, as end-to-end proof that the crate actually resolves and installs via the registry index — the scenario that workspace-path dependencies defeat and that killed the rc.1 first-publish. ## What's new - **EventType variants**: \`RehearsalSmokeCheckStarted\`, \`RehearsalSmokeCheckSucceeded\`, \`RehearsalSmokeCheckFailed\`. - **RuntimeOptions**: \`rehearsal_smoke_install: Option<String>\`. - **CliOverrides + CLI flag**: \`--smoke-install <CRATE>\` (global). Crate must be in the rehearsal plan and have a \`[[bin]]\` target; library-only crates can't be smoke-installed directly (consumer- workspace build variant is a follow-on). - **\`ops::cargo::cargo_install_smoke\`**: wrapper around \`cargo install --registry <name> <crate> --version <v> --root <tmp> --force --locked\`. - **engine::run_rehearsal** post-publish step: if all packages passed AND \`--smoke-install\` names an in-plan crate, install it from the rehearsal registry. Install failure fails the whole rehearsal (sets \`first_failure\` + \`RehearsalComplete { passed: false }\`). Named-but-not-in-plan: warn only; don't fail rehearsal over a typo. ## Tests - \`run_rehearsal_smoke_install_happy_path_emits_succeeded_event\` — flag named an in-plan crate, fake cargo returns 0, assert \`rehearsal_smoke_check_started\` + \`_succeeded\` events. - \`run_rehearsal_smoke_install_missing_target_warns_without_failing\` — flag named a crate not in the plan, assert reporter warn + rehearsal still passes. 19 rehearsal+gate tests pass (up from 17). ## Closes #97 With this PR, #97's issue checklist is 100% fulfilled: - [x] Publish packaged tarballs to alt registry (#127) - [x] Verify presence on rehearsal registry (#127) - [x] Install/build from rehearsal registry (**this PR**) - [x] Record proof in RehearsalComplete event (#127) - [x] Hard gate binding on plan_id (#133) The \`#97\` issue can be closed once this merges. * test: rebaseline shipper-config snapshots for rehearsal_smoke_install field * test: rebaseline shipper-types RuntimeOptions debug snapshot for smoke-install field
Summary
Phase-2 preflight execution — the code that makes #97's config flags actually DO something. PR 1 (#120) added `RehearsalConfig` + CLI flag plumbing; this PR:
mechanism live publish uses,
`events.jsonl` so the outcome is auditable from the event log alone.
Exposed as `shipper rehearse`.
Key invariants
Fails clean with a fix-suggesting error otherwise.
Refuses otherwise — the whole point is a sandbox.
an execution. Event log only, so the regular publish flow sees a
clean state dir.
returned so the eventual hard gate can detect it.
Scope — explicitly NOT in this PR
rehearsal (Rehearsal registry: strengthen preflight from dry-run to actual publish + install proof #97 PR 3).
— needs a consumer-workspace fixture).
gate; simpler to land together).
once-per-release rehearsal).
Tests
6 unit tests in `engine::tests`, all passing:
CLI snapshots regenerated to reflect the new `rehearse` subcommand in
help output.
Test plan