Skip to content

feat(rehearse): hard gate — run_publish requires passing rehearsal (#97 PR 3)#128

Closed
EffortlessSteven wants to merge 2 commits into
feat/97-rehearse-executionfrom
feat/97-rehearsal-hard-gate
Closed

feat(rehearse): hard gate — run_publish requires passing rehearsal (#97 PR 3)#128
EffortlessSteven wants to merge 2 commits into
feat/97-rehearse-executionfrom
feat/97-rehearsal-hard-gate

Conversation

@EffortlessSteven

Copy link
Copy Markdown
Member

Summary

Close the rehearsal loop. PR 2 (#127) 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`.

Base branch is #127, not main. Merge #127 first.

Gate decision tree

Evaluated at the top of `run_publish`:

  1. `opts.rehearsal_registry = None` → dormant; publish proceeds. Unchanged behavior for every caller who hasn't opted into rehearsal.
  2. `opts.rehearsal_skip = true` → proceed with a loud warning. No fake-passing receipt is synthesized; `events.jsonl` shows no `RehearsalComplete` for this `plan_id` so auditors see the bypass honestly.
  3. No `rehearsal.json` → refuse with a fix-suggesting error (`run shipper rehearse first`).
  4. Receipt's `plan_id` mismatches current plan_id → refuse (stale — workspace changed since rehearsal).
  5. `passed: false` → refuse, citing the rehearsal summary.
  6. Fresh passing receipt for current plan → proceed; info-log a confirmation.

Persistence

New sidecar: `state_dir/rehearsal.json` (`shipper.rehearsal.v1` schema). Atomic write via `.tmp` + rename + fsync, crash-safe. Small enough to `cat` and grok. Shape:

```json
{
"schema_version": "shipper.rehearsal.v1",
"plan_id": "...",
"registry": "...",
"passed": true,
"packages_attempted": 3,
"packages_published": 3,
"summary": "...",
"started_at": "...",
"completed_at": "..."
}
```

Event schema additive

`RehearsalStarted` and `RehearsalComplete` now carry `plan_id`. Lets an auditor replay the gate's decision from `events.jsonl` alone. Additive on events that only shipped with PR 2 (#127), so no migration needed for landed state.

Tests

13 new unit tests (total rehearsal+gate coverage: 17):

  • 3 sidecar roundtrip/persistence tests
  • 6 gate-helper tests: dormant / skip / missing / stale / failing / fresh-pass
  • 1 e2e test that `run_publish` actually invokes the gate and bails when rehearsal is required but missing

Scope — explicitly NOT in this PR

  • Install/smoke check against the rehearsal registry (PR 4 or follow-on). Gate currently trusts publish + visibility.
  • CI recipe for kellnr sidecar (docs PR, not code).

Test plan

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f649f10-a544-4af8-956f-aa24d2580eb6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/97-rehearsal-hard-gate

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.

@EffortlessSteven EffortlessSteven force-pushed the feat/97-rehearsal-hard-gate branch from 61b8fa3 to 2b22bf7 Compare April 17, 2026 21:43

@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: 61b8fa32d6

ℹ️ 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 +417 to +421
if receipt.plan_id != ws.plan.plan_id {
bail!(
"rehearsal receipt is stale: rehearsal ran for plan_id {} but the current plan_id is {}. \
The workspace changed between rehearse and publish; re-run `shipper rehearse` against the current plan.",
receipt.plan_id,

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 Reject receipts from a different rehearsal registry

The hard gate accepts any receipt that matches plan_id and passed, but it never verifies that the receipt was produced against the currently configured rehearsal_registry. If the configured rehearsal target changes (or is mistyped), an older passing receipt from a different registry can still authorize live publish, which bypasses the intended guarantee that publish is gated on rehearsal against the configured sandbox.

Useful? React with 👍 / 👎.

Comment on lines +76 to +78
fs::rename(&tmp, &path)
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
Ok(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fsync parent directory after writing rehearsal receipt

This write path fsyncs the temp file and renames it, but does not fsync the parent directory after the rename. After a crash/power loss, the renamed rehearsal.json entry can be lost even though rehearsal reported success, which can cause a later publish to fail the gate as if no rehearsal happened. The state persistence path already does a parent-dir fsync for this reason, so this path should match that durability behavior.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Apr 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.92531% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/shipper/src/engine/mod.rs 97.02% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@EffortlessSteven EffortlessSteven force-pushed the feat/97-rehearse-execution branch from 827ca17 to 87c5604 Compare April 18, 2026 00:09
… 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).
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.
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