Skip to content

feat(rehearse): engine::run_rehearsal + shipper rehearse CLI (#97 PR 2)#127

Merged
EffortlessSteven merged 1 commit into
mainfrom
feat/97-rehearse-execution
Apr 18, 2026
Merged

feat(rehearse): engine::run_rehearsal + shipper rehearse CLI (#97 PR 2)#127
EffortlessSteven merged 1 commit into
mainfrom
feat/97-rehearse-execution

Conversation

@EffortlessSteven

Copy link
Copy Markdown
Member

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:

  • publishes every crate in the plan to an alternate registry,
  • verifies visibility on that registry via the same `version_exists`
    mechanism live publish uses,
  • emits a `RehearsalComplete { passed, registry, summary }` event to
    `events.jsonl` so the outcome is auditable from the event log alone.

Exposed as `shipper rehearse`.

Key invariants

  • Rehearsal registry must resolve to an entry in `opts.registries`.
    Fails clean with a fix-suggesting error otherwise.
  • Rehearsal registry must differ from the live target (`ws.plan.registry`).
    Refuses otherwise — the whole point is a sandbox.
  • Sequential execution; stops at first failure.
  • Does NOT touch `state.json`. Rehearsal is a pre-publish proof, not
    an execution. Event log only, so the regular publish flow sees a
    clean state dir.
  • `--skip-rehearsal` honored with a loud warning; outcome still
    returned so the eventual hard gate can detect it.

Scope — explicitly NOT in this PR

  • Hard gate wiring `run_publish` → refuse without a passing
    rehearsal (Rehearsal registry: strengthen preflight from dry-run to actual publish + install proof #97 PR 3).
  • Install / smoke check against the rehearsal registry (PR 3 or 4
    — needs a consumer-workspace fixture).
  • plan_id binding of rehearsal outcome (needed alongside the hard
    gate; simpler to land together).
  • Parallel rehearsal (not planned — correctness > speed for a
    once-per-release rehearsal).

Tests

6 unit tests in `engine::tests`, all passing:

  • errors cleanly with no rehearsal registry
  • errors cleanly when rehearsal == live target
  • errors cleanly when the named registry isn't configured
  • `--skip-rehearsal` returns immediately, no events written
  • happy path emits Started + PackagePublished + Complete(passed=true)
  • cargo-failure path emits PackageFailed + Complete(passed=false)

CLI snapshots regenerated to reflect the new `rehearse` subcommand in
help output.

Test plan

  • `cargo test -p shipper --lib run_rehearsal` — 6/6 pass
  • `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean
  • `cargo fmt --all --check` clean
  • `shipper rehearse --help` surfaces the new subcommand correctly
  • CI multi-OS green

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@EffortlessSteven has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 40 minutes and 2 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4b036848-a704-434f-8508-3cc8fa3565b1

📥 Commits

Reviewing files that changed from the base of the PR and between 6e95b83 and 87c5604.

⛔ Files ignored due to path filters (24)
  • crates/shipper-cli/tests/snapshots/cli_snapshots__help_text.snap is excluded by !**/*.snap
  • crates/shipper-cli/tests/snapshots/cli_snapshots__no_subcommand_error.snap is excluded by !**/*.snap
  • crates/shipper-cli/tests/snapshots/e2e_expanded__help_root.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__policy_combination_tests__snapshot_balanced_policy_typical.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__policy_combination_tests__snapshot_fast_policy_typical.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__policy_combination_tests__snapshot_safe_policy_typical.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_all_flags_enabled.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_alternative_registry_only.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_balanced_policy_with_partial_config.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_default_conversion.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_encryption_with_env_var.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_fast_policy_no_verify.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_full_readiness_config.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_linear_retry_strategy.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_parallel_heavy.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_resume_from_set.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_safe_policy_max_safety.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_webhook_with_secret.snap is excluded by !**/*.snap
  • crates/shipper-config/src/runtime/snapshots/shipper_config__runtime__tests__snapshot_tests__snapshot_with_registries.snap is excluded by !**/*.snap
  • crates/shipper-config/src/snapshots/shipper_config__tests__edge_case_snapshots__edge_policy_balanced_runtime.snap is excluded by !**/*.snap
  • crates/shipper-config/src/snapshots/shipper_config__tests__edge_case_snapshots__edge_policy_fast_runtime.snap is excluded by !**/*.snap
  • crates/shipper-config/src/snapshots/shipper_config__tests__edge_case_snapshots__edge_policy_safe_runtime.snap is excluded by !**/*.snap
  • crates/shipper-config/src/snapshots/shipper_config__tests__snapshot_tests__merge_cli_overrides_file_values.snap is excluded by !**/*.snap
  • crates/shipper-types/src/snapshots/shipper_types__tests__debug_snapshots__runtime_options_debug_snapshot.snap is excluded by !**/*.snap
📒 Files selected for processing (8)
  • crates/shipper-cli/src/main.rs
  • crates/shipper-config/src/lib.rs
  • crates/shipper-config/src/runtime/mod.rs
  • crates/shipper-config/tests/config_runtime_bdd.rs
  • crates/shipper-types/src/lib.rs
  • crates/shipper/src/engine/mod.rs
  • crates/shipper/src/engine/parallel/tests.rs
  • crates/shipper/src/runtime/policy/mod.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/97-rehearse-execution

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +583 to +589
println!(
"rehearsal FAILED after {}/{} packages against '{}': {}",
outcome.packages_published,
outcome.packages_attempted,
outcome.registry_name,
outcome.summary
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
);

Comment on lines +6487 to +6489
#[test]
#[serial]
fn run_rehearsal_happy_path_emits_started_published_complete_events() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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:

  1. Mocking cargo::cargo_publish to return a successful exit code.
  2. Mocking the registry server to return a 404 for the version_exists check.
  3. Asserting that run_rehearsal returns a RehearsalOutcome with passed: false.
  4. Asserting that a RehearsalPackageFailed event is emitted with ErrorClass::Ambiguous.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +1301 to +1304
.registries
.iter()
.find(|r| r.name == rehearsal_name)
.cloned()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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)? {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

EffortlessSteven added a commit that referenced this pull request Apr 17, 2026
… 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).
EffortlessSteven added a commit that referenced this pull request Apr 17, 2026
… 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

codecov Bot commented Apr 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.24138% with 27 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/shipper/src/engine/mod.rs 94.49% 17 Missing ⚠️
crates/shipper-cli/src/main.rs 30.76% 9 Missing ⚠️
crates/shipper-config/src/lib.rs 83.33% 1 Missing ⚠️

📢 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).
@EffortlessSteven EffortlessSteven force-pushed the feat/97-rehearse-execution branch from 827ca17 to 87c5604 Compare April 18, 2026 00:09
EffortlessSteven added a commit that referenced this pull request Apr 18, 2026
… 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).
@EffortlessSteven EffortlessSteven merged commit a84209f into main Apr 18, 2026
19 checks passed
@EffortlessSteven EffortlessSteven deleted the feat/97-rehearse-execution branch April 18, 2026 00:11
EffortlessSteven added a commit that referenced this pull request Apr 18, 2026
… 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).
EffortlessSteven added a commit that referenced this pull request Apr 18, 2026
… 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.
EffortlessSteven added a commit that referenced this pull request Apr 18, 2026
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.
EffortlessSteven added a commit that referenced this pull request Apr 18, 2026
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.
EffortlessSteven added a commit that referenced this pull request Apr 18, 2026
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.
EffortlessSteven added a commit that referenced this pull request Apr 18, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant