feat(yank): shipper yank <crate>@<version> (#98 PR 1)#121
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 16 minutes and 58 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 (4)
📒 Files selected for processing (4)
✨ 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 introduces the yank command to the shipper-cli, enabling users to mark specific crate versions as non-installable in the registry. The changes include a new cargo_yank wrapper in the library, an updated EventType for event logging, and comprehensive E2E and unit tests. Feedback highlights the need for consistent registry handling in multi-registry setups, optimization of string allocations by moving variables instead of cloning, and stricter adherence to the project's 'events-as-truth' invariant by treating event log write failures as fatal errors.
| let registry_name = opts | ||
| .registries | ||
| .first() | ||
| .map(|r| r.name.clone()) | ||
| .unwrap_or_else(|| "crates-io".to_string()); |
There was a problem hiding this comment.
The logic for selecting the registry for the yank command could lead to unexpected behavior in a multi-registry configuration. It currently selects the first registry from opts.registries. If a user has multiple registries configured (e.g., via .shipper.toml), the yank command will silently only operate on the first one in the list. This is inconsistent with other commands like publish and status which iterate over all configured registries.
To avoid this ambiguity, I suggest one of two approaches:
- Explicitly iterate over all registries in
opts.registriesfor theyankcommand, similar to how other commands behave. - If
yankis intended to only work on a single registry at a time, consider getting the registry name more directly from the--registryflag, and perhaps error out ifopts.registriescontains more than one entry and--registryis not specified. This would make the behavior more explicit for the user.
| crate_name: crate_name.clone(), | ||
| version: version.clone(), | ||
| reason: reason.clone(), |
There was a problem hiding this comment.
The crate_name, version, and reason strings are being cloned here, but since they are owned by the Yank command scope and are not used after being passed to EventType::PackageYanked, they can be moved instead. This avoids unnecessary allocations.
| crate_name: crate_name.clone(), | |
| version: version.clone(), | |
| reason: reason.clone(), | |
| crate_name, | |
| version, | |
| reason, |
| if let Err(err) = log.write_to_file(&events_file) { | ||
| reporter.warn(&format!( | ||
| "failed to append PackageYanked event to {}: {err:#}", | ||
| events_file.display() | ||
| )); | ||
| } |
There was a problem hiding this comment.
A failure to write the PackageYanked event to events.jsonl is currently handled as a warning. According to the project's invariants (docs/INVARIANTS.md), events.jsonl is the authoritative source of truth. If a yank operation succeeds but is not recorded, the system's state becomes inconsistent with its history. This could cause issues with auditing and future tooling that relies on the event log.
Consider making this a hard error by using reporter.error() and returning an Err result, to ensure that any successful yank is always recorded.
| if let Err(err) = log.write_to_file(&events_file) { | |
| reporter.warn(&format!( | |
| "failed to append PackageYanked event to {}: {err:#}", | |
| events_file.display() | |
| )); | |
| } | |
| if let Err(err) = log.write_to_file(&events_file) { | |
| reporter.error(&format!( | |
| "failed to append PackageYanked event to {}: {err:#}", | |
| events_file.display() | |
| )); | |
| anyhow::bail!("failed to record yank event"); | |
| } |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 76ccb80434
ℹ️ 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".
| let registry_name = opts | ||
| .registries | ||
| .first() | ||
| .map(|r| r.name.clone()) | ||
| .unwrap_or_else(|| "crates-io".to_string()); |
There was a problem hiding this comment.
Use selected registry when invoking cargo yank
This picks registry_name from opts.registries, but that list is empty in normal single-registry flows (including --registry <name>), so the command silently falls back to crates-io. As a result, shipper yank --registry private-reg ... still targets crates.io instead of the requested registry unless multi-registry flags are used, which can yank the wrong destination.
Useful? React with 👍 / 👎.
| let workspace_root = | ||
| std::env::current_dir().context("failed to resolve current dir for cargo yank")?; |
There was a problem hiding this comment.
Run cargo yank from the resolved workspace root
The yank command ignores the resolved workspace/manifest location and always uses the process current directory as Cargo's working directory. When --manifest-path points to a different workspace, Cargo will evaluate config relative to the wrong tree, so registry mappings or auth settings in the target workspace's .cargo/config.toml may not apply and yank can fail unexpectedly.
Useful? React with 👍 / 👎.
First primitive of the Remediate pillar (#98). Wraps `cargo yank` as containment, not undo — existing lockfile pins stay pinned; the yank only prevents NEW resolves against that version. - ops/cargo: cargo_yank(workspace, name, version, registry, lines, timeout) mirrors the cargo_publish invariants (non-default registry passthrough, timeout polling, output redaction + tailing via output-sanitizer). - CLI: `shipper yank --crate <name> --version <ver> --reason <why>` emits a PackageYanked event to <state_dir>/events.jsonl capturing the reason and the cargo exit code; surfaces a loud operator warning about containment semantics and the `cargo update -p <name>` remediation step for downstream consumers. - types: PackageYanked event variant (crate_name, version, reason, exit_code). - tests: three fake-cargo unit tests in ops/cargo (args passthrough, crates-io registry omission, non-zero exit propagation) + a yank --help snapshot in e2e_expanded. Scope-limited on purpose: follow-on PRs under #98 compose this into `shipper plan-yank` (reverse-topological containment plan) and `shipper fix-forward` (patch-and-republish).
76ccb80 to
0708fed
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…ds (#98 PR 2) Next step on the Remediate pillar. PR 1 (#121) landed the single-crate \`shipper yank\` primitive; this PR composes it into a reverse-topological containment plan built from a prior publish receipt. ## What's new - **Receipt schema** (additive): PackageReceipt gains three Option fields, all \`#[serde(default, skip_serializing_if = \"Option::is_none\")]\` so existing receipts read back unchanged: - \`compromised_at: Option<DateTime<Utc>>\` — when this version was marked compromised - \`compromised_by: Option<String>\` — operator-supplied reason (CVE, incident, free-form tag) - \`superseded_by: Option<String>\` — populated by \`shipper fix-forward\` (#98 PR 3) - **\`shipper::engine::plan_yank\`** module: - \`PlanYankFilter::{AllPublished, CompromisedOnly}\` selector - \`YankPlan\`, \`YankEntry\` value types - \`build_plan(receipt, filter)\` — filter receipt.packages, reverse to get dependents-first order, emit entries - \`render_text(plan)\` — produces copy-pasteable \`shipper yank ...\` command lines, one per entry, with \`--reason <REASON>\` placeholders for the operator to fill in - \`load_receipt_from_path\` — receipt loader for arbitrary paths (default is \`<state_dir>/receipt.json\`) - **\`shipper plan-yank\` CLI subcommand**: \`\`\` shipper plan-yank [--from-receipt PATH] [--compromised-only] \`\`\` Honors the global \`--format\` flag: \`text\` (default) prints the copy-pasteable yank-command list; \`json\` prints structured YankPlan for scripting. ## Scope — explicitly NOT in this PR - **Plan execution** (wrapping \`shipper yank\` to run each entry) → #98 PR 3 along with fix-forward. - **\`--mark-compromised\`** (populating the new receipt fields programmatically) → #98 PR 3. For now, operators can mark packages compromised by hand-editing receipt.json or via downstream tooling. - **release_compromised state** (higher-level "this release is a write-off") → #98 PR 3. ## Why reverse topological, specifically For workspace A → B → C (A is a leaf, B depends on A, C depends on B): - Publish order: A, B, C - **Yank order: C, B, A** — dependents first. New resolves for C will still pull B (if B is not yet yanked), but at least C itself is unresolvable. Yanking A first would be fine semantically but can mislead operators who read \`cargo search\` output. Plus: reverse-topo matches the containment mental model users bring from incident response — "pull the thing furthest from your reach first, work back toward the root." ## Tests 5 unit tests in \`engine::plan_yank::tests\` (all green): - reverses publish order for \`AllPublished\` - excludes Failed/Skipped packages (only Published gets a yank entry) - \`CompromisedOnly\` filter drops healthy packages, keeps only flagged - empty receipt → empty plan with a helpful comment - text-render ordering invariant: dependents appear before dependencies Plus a CLI \`plan-yank --help\` snapshot in \`e2e_expanded\`. ## Companion churn Mechanical: every PackageReceipt construction site (engine + state tests + integration tests) now sets the three new Option fields to None. The schema is additive; no runtime semantics changed for existing code paths.
Stitch the Remediate pillar together with a step-by-step operator guide that walks through yank --mark-compromised → fix-forward plan → publish → optional plan-yank finalize. Tied to docs/README.md's how-to index so operators can find it without folklore. Covers: - 4-step remediation flow with concrete commands - Worked example: single-CVE in a multi-crate workspace - Explicit statement of what yank does NOT do (lockfile invalidation, version bumping, consumer notification) No code changes. This only references commands added in this branch stack (#121 yank, #129 plan-yank, #131 mark-compromised + fix-forward).
…ds (#98 PR 2) Next step on the Remediate pillar. PR 1 (#121) landed the single-crate \`shipper yank\` primitive; this PR composes it into a reverse-topological containment plan built from a prior publish receipt. ## What's new - **Receipt schema** (additive): PackageReceipt gains three Option fields, all \`#[serde(default, skip_serializing_if = \"Option::is_none\")]\` so existing receipts read back unchanged: - \`compromised_at: Option<DateTime<Utc>>\` — when this version was marked compromised - \`compromised_by: Option<String>\` — operator-supplied reason (CVE, incident, free-form tag) - \`superseded_by: Option<String>\` — populated by \`shipper fix-forward\` (#98 PR 3) - **\`shipper::engine::plan_yank\`** module: - \`PlanYankFilter::{AllPublished, CompromisedOnly}\` selector - \`YankPlan\`, \`YankEntry\` value types - \`build_plan(receipt, filter)\` — filter receipt.packages, reverse to get dependents-first order, emit entries - \`render_text(plan)\` — produces copy-pasteable \`shipper yank ...\` command lines, one per entry, with \`--reason <REASON>\` placeholders for the operator to fill in - \`load_receipt_from_path\` — receipt loader for arbitrary paths (default is \`<state_dir>/receipt.json\`) - **\`shipper plan-yank\` CLI subcommand**: \`\`\` shipper plan-yank [--from-receipt PATH] [--compromised-only] \`\`\` Honors the global \`--format\` flag: \`text\` (default) prints the copy-pasteable yank-command list; \`json\` prints structured YankPlan for scripting. ## Scope — explicitly NOT in this PR - **Plan execution** (wrapping \`shipper yank\` to run each entry) → #98 PR 3 along with fix-forward. - **\`--mark-compromised\`** (populating the new receipt fields programmatically) → #98 PR 3. For now, operators can mark packages compromised by hand-editing receipt.json or via downstream tooling. - **release_compromised state** (higher-level "this release is a write-off") → #98 PR 3. ## Why reverse topological, specifically For workspace A → B → C (A is a leaf, B depends on A, C depends on B): - Publish order: A, B, C - **Yank order: C, B, A** — dependents first. New resolves for C will still pull B (if B is not yet yanked), but at least C itself is unresolvable. Yanking A first would be fine semantically but can mislead operators who read \`cargo search\` output. Plus: reverse-topo matches the containment mental model users bring from incident response — "pull the thing furthest from your reach first, work back toward the root." ## Tests 5 unit tests in \`engine::plan_yank::tests\` (all green): - reverses publish order for \`AllPublished\` - excludes Failed/Skipped packages (only Published gets a yank entry) - \`CompromisedOnly\` filter drops healthy packages, keeps only flagged - empty receipt → empty plan with a helpful comment - text-render ordering invariant: dependents appear before dependencies Plus a CLI \`plan-yank --help\` snapshot in \`e2e_expanded\`. ## Companion churn Mechanical: every PackageReceipt construction site (engine + state tests + integration tests) now sets the three new Option fields to None. The schema is additive; no runtime semantics changed for existing code paths.
Stitch the Remediate pillar together with a step-by-step operator guide that walks through yank --mark-compromised → fix-forward plan → publish → optional plan-yank finalize. Tied to docs/README.md's how-to index so operators can find it without folklore. Covers: - 4-step remediation flow with concrete commands - Worked example: single-CVE in a multi-crate workspace - Explicit statement of what yank does NOT do (lockfile invalidation, version bumping, consumer notification) No code changes. This only references commands added in this branch stack (#121 yank, #129 plan-yank, #131 mark-compromised + fix-forward).
…PR 2) (#132) * feat(remediate): plan-yank command + compromised-release receipt fields (#98 PR 2) Next step on the Remediate pillar. PR 1 (#121) landed the single-crate \`shipper yank\` primitive; this PR composes it into a reverse-topological containment plan built from a prior publish receipt. ## What's new - **Receipt schema** (additive): PackageReceipt gains three Option fields, all \`#[serde(default, skip_serializing_if = \"Option::is_none\")]\` so existing receipts read back unchanged: - \`compromised_at: Option<DateTime<Utc>>\` — when this version was marked compromised - \`compromised_by: Option<String>\` — operator-supplied reason (CVE, incident, free-form tag) - \`superseded_by: Option<String>\` — populated by \`shipper fix-forward\` (#98 PR 3) - **\`shipper::engine::plan_yank\`** module: - \`PlanYankFilter::{AllPublished, CompromisedOnly}\` selector - \`YankPlan\`, \`YankEntry\` value types - \`build_plan(receipt, filter)\` — filter receipt.packages, reverse to get dependents-first order, emit entries - \`render_text(plan)\` — produces copy-pasteable \`shipper yank ...\` command lines, one per entry, with \`--reason <REASON>\` placeholders for the operator to fill in - \`load_receipt_from_path\` — receipt loader for arbitrary paths (default is \`<state_dir>/receipt.json\`) - **\`shipper plan-yank\` CLI subcommand**: \`\`\` shipper plan-yank [--from-receipt PATH] [--compromised-only] \`\`\` Honors the global \`--format\` flag: \`text\` (default) prints the copy-pasteable yank-command list; \`json\` prints structured YankPlan for scripting. ## Scope — explicitly NOT in this PR - **Plan execution** (wrapping \`shipper yank\` to run each entry) → #98 PR 3 along with fix-forward. - **\`--mark-compromised\`** (populating the new receipt fields programmatically) → #98 PR 3. For now, operators can mark packages compromised by hand-editing receipt.json or via downstream tooling. - **release_compromised state** (higher-level "this release is a write-off") → #98 PR 3. ## Why reverse topological, specifically For workspace A → B → C (A is a leaf, B depends on A, C depends on B): - Publish order: A, B, C - **Yank order: C, B, A** — dependents first. New resolves for C will still pull B (if B is not yet yanked), but at least C itself is unresolvable. Yanking A first would be fine semantically but can mislead operators who read \`cargo search\` output. Plus: reverse-topo matches the containment mental model users bring from incident response — "pull the thing furthest from your reach first, work back toward the root." ## Tests 5 unit tests in \`engine::plan_yank::tests\` (all green): - reverses publish order for \`AllPublished\` - excludes Failed/Skipped packages (only Published gets a yank entry) - \`CompromisedOnly\` filter drops healthy packages, keeps only flagged - empty receipt → empty plan with a helpful comment - text-render ordering invariant: dependents appear before dependencies Plus a CLI \`plan-yank --help\` snapshot in \`e2e_expanded\`. ## Companion churn Mechanical: every PackageReceipt construction site (engine + state tests + integration tests) now sets the three new Option fields to None. The schema is additive; no runtime semantics changed for existing code paths. * fix(plan-yank): appease Linux rustfmt + drop broken intra-doc link
Stitch the Remediate pillar together with a step-by-step operator guide that walks through yank --mark-compromised → fix-forward plan → publish → optional plan-yank finalize. Tied to docs/README.md's how-to index so operators can find it without folklore. Covers: - 4-step remediation flow with concrete commands - Worked example: single-CVE in a multi-crate workspace - Explicit statement of what yank does NOT do (lockfile invalidation, version bumping, consumer notification) No code changes. This only references commands added in this branch stack (#121 yank, #129 plan-yank, #131 mark-compromised + fix-forward).
Stitch the Remediate pillar together with a step-by-step operator guide that walks through yank --mark-compromised → fix-forward plan → publish → optional plan-yank finalize. Tied to docs/README.md's how-to index so operators can find it without folklore. Covers: - 4-step remediation flow with concrete commands - Worked example: single-CVE in a multi-crate workspace - Explicit statement of what yank does NOT do (lockfile invalidation, version bumping, consumer notification) No code changes. This only references commands added in this branch stack (#121 yank, #129 plan-yank, #131 mark-compromised + fix-forward).
…134) * feat(remediate): --mark-compromised + shipper fix-forward (#98 PR 3) Close the remediation staged rollout: yank (PR 1) + plan-yank (PR 2) + **fix-forward planning (this PR)** give operators the three building blocks for handling a compromised release without improvising. ## What's new ### \`shipper yank --mark-compromised\` New opt-in flag on the existing yank primitive. When set, after a successful \`cargo yank\`, shipper also amends the matching \`PackageReceipt\` in \`<state_dir>/receipt.json\` with: - \`compromised_at = Utc::now()\` - \`compromised_by = <--reason>\` These are the fields added (additively) in PR 2 (#129). The amendment lets downstream commands find compromised packages without scanning events.jsonl. Behaviour is tolerant: - No receipt → warn + proceed (yank still succeeds) - No matching package in receipt → warn + proceed - Load/write errors → warn + proceed (yank already happened; a receipt-write failure doesn't retroactively unyank) ### \`shipper fix-forward --from-receipt <path>\` New planning command. Reads a receipt, finds packages flagged \`compromised_at = Some(_)\`, and prints an ordered supersession plan: - Topological order (dependencies first, dependents last) — opposite of plan-yank, because fix-forward *publishes* replacements while plan-yank *removes* reachability. - Suggested successor versions use a placeholder format (\`<old>-next\`) so the operator picks the real bump (patch / minor / major). - Instructional preamble walks through the full remediation loop: bump Cargo.toml → commit → \`shipper publish\` → optional \`shipper plan-yank --compromised-only\` for containment. Like plan-yank, fix-forward is **planning only**. It does not edit Cargo.toml or invoke publish — those are operator steps. ## Tests 4 new tests in \`engine::fix_forward::tests\`: - only compromised+Published packages produce steps (Failed-but- compromised packages are silently dropped — they were never shipped) - topological order preserved from receipt.packages - empty plan (no compromised packages) renders with guidance toward \`--mark-compromised\` - text render enumerates steps with reason and points toward the publish + plan-yank follow-ups Plus CLI snapshots for \`yank --help\` (gains \`--mark-compromised\`) and \`fix-forward --help\`. ## Scope — explicitly NOT in this PR - **Automated Cargo.toml bump.** Workspace-edit territory; needs its own PR with --dry-run, --apply, and git-cleanliness guards. - **Publishing successors.** \`shipper publish\` already does this; fix- forward just tells the operator when to run it. - **Chaining \`superseded_by\` back to the original receipt.** Requires post-publish receipt amendment from the successor run. Follow-on. - **\`release_compromised\` top-level state.** The receipt-level marker. For now the per-package \`compromised_at\` fields cover the planning-level need. * docs: how-to for remediating a compromised release (#98) Stitch the Remediate pillar together with a step-by-step operator guide that walks through yank --mark-compromised → fix-forward plan → publish → optional plan-yank finalize. Tied to docs/README.md's how-to index so operators can find it without folklore. Covers: - 4-step remediation flow with concrete commands - Worked example: single-CVE in a multi-crate workspace - Explicit statement of what yank does NOT do (lockfile invalidation, version bumping, consumer notification) No code changes. This only references commands added in this branch stack (#121 yank, #129 plan-yank, #131 mark-compromised + fix-forward).
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.
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.
Summary
Opens the Remediate pillar (#98) with its first primitive: a single
shipper yank <crate>@<version>command. Containment, not undo —existing lockfile pins stay pinned; the yank only prevents NEW resolves
against that version. Downstream consumers get told to
cargo update -p <name>.cargo_yank(workspace, name, version, registry, lines, timeout),mirrors
cargo_publishinvariants (non-default registry passthrough,timeout polling, output redaction + tailing via
shipper-output-sanitizer).shipper yank --crate <NAME> --version <VERSION> --reason <REASON>PackageYanked { crate_name, version, reason, exit_code }to<state_dir>/events.jsonl— the reason is operator-supplied(e.g.
"CVE-2026-0001 disclosed; containing while patch released")and enters the event log alongside publish events.
guidance (
cargo update -p <NAME>).EventType::PackageYankedvariant.ops/cargo(args passthrough,crates-io registry omission, non-zero exit propagation)
yank --helpsnapshot ine2e_expandedScope
Intentionally tight. Follow-on PRs under #98 compose this primitive into:
shipper plan-yank— reverse-topological containment planshipper fix-forward— patch, publish, update-pinReceipt augmentation (compromised_at/by/superseded_by) is not in
this PR — it was explored and pulled to tighten scope; will land on the
plan-yank PR where the schema change pays for itself.
Test plan
cargo test -p shipper --lib ops::cargo::tests::cargo_yank— 3/3 passcargo test -p shipper-cli --test e2e_expanded help_yank_snapshot— greencargo fmt --all— cleancargo clippy -p shipper -p shipper-cli --all-targets --all-features -- -D warnings— no issues