Skip to content

feat: implement shipper - reliable Rust workspace publisher#1

Merged
EffortlessSteven merged 15 commits into
mainfrom
initial-implementation
Feb 15, 2026
Merged

feat: implement shipper - reliable Rust workspace publisher#1
EffortlessSteven merged 15 commits into
mainfrom
initial-implementation

Conversation

@EffortlessSteven

Copy link
Copy Markdown
Member

Summary

  • Implements the complete shipper tool for reliable, resumable publishing of Rust workspace crates to registries
  • Two-crate workspace: shipper (core library with all domain logic) and shipper-cli (thin CLI binary using clap)
  • Full publishing pipeline: plan → preflight → publish → resume, with topological dependency ordering, retry/backoff, registry verification, and crash-safe state persistence

Changes

Core library (crates/shipper/)

  • plan.rs — Workspace analysis via cargo_metadata, topological sort (Kahn's algorithm), deterministic plan ID generation (SHA256)
  • engine.rs — Sequential publish engine with preflight checks, retry/backoff, registry verification, and Finishability assessment
  • engine_parallel.rs — Parallel publish engine processing crates by dependency level
  • auth.rs — Token resolution following Cargo conventions (CARGO_REGISTRY_TOKEN → credentials.toml)
  • registry.rs — Registry interaction: API and sparse index visibility checks, ownership verification, version existence
  • state.rs — Schema-versioned execution state persistence with v1→v2 migration
  • store.rsStateStore trait abstraction for persistence (filesystem-backed)
  • events.rs — Append-only JSONL event logging
  • config.rs.shipper.toml configuration with CLI override merging
  • git.rs — Git cleanliness checks and context collection
  • lock.rs — Distributed lock preventing concurrent publishes
  • types.rs — Domain types with proptest roundtrip coverage
  • environment.rs — Environment fingerprinting for audit receipts

CLI (crates/shipper-cli/)

  • main.rs — clap-based CLI with commands: plan, preflight, publish, resume, status, inspect-receipt, doctor, config init/validate, ci github/gitlab

Tests

  • 247 unit tests (library) + 13 unit tests (CLI) + 20 integration/e2e tests
  • Property-based tests via proptest for type roundtrips
  • Snapshot tests via insta
  • Mock registry servers via tiny_http (no real registry calls)
  • Fuzz targets for state loading and token resolution

Documentation & CI

  • docs/ — configuration, failure modes, preflight, readiness documentation
  • .github/workflows/ci.yml — CI pipeline
  • templates/ — GitHub trusted publishing and GitLab CI templates
  • CHANGELOG.md, RELEASE_NOTES_v0.2.0.md, RELEASE_CHECKLIST_v0.2.0.md

Evidence

  • Commits: 15
  • Tests: 280 passed, 0 failed
  • Lint: clean (clippy with -D warnings)
  • Files: 44 changed, +19242/-21 lines

Test Plan

  • All 280 tests pass (cargo test — unit, integration, e2e)
  • Clippy clean with all warnings denied
  • E2e tests cover: plan/preflight/publish/resume/status/doctor/inspect-receipt/config commands
  • Registry interactions are fully mocked via tiny_http servers
  • State persistence tested via roundtrip save/load/migration tests
  • Property-based tests verify serialization roundtrips for all domain types

Unknowns

  • No real registry publishing tested (all mocked) — first real publish will be the true validation
  • Parallel publishing engine has unit tests but no e2e coverage yet
  • Fuzz targets exist but extended fuzzing campaigns haven't been run
  • Windows-specific path handling in lock/state files not exhaustively tested

- Introduced `shipper` crate with core functionality for publishing Rust workspaces.
- Implemented token resolution for registry authentication via environment variables and credentials files.
- Added cargo command execution for publishing packages with options for dirty workspace and verification.
- Developed preflight checks for package ownership and version existence before publishing.
- Implemented state management for tracking publish progress and handling retries.
- Added support for registry interactions to check version existence and list package owners.
- Created utility functions for managing state and receipts in JSON format.
- Added documentation on failure modes and GitHub/GitLab CI templates for automated publishing.
- Introduced a new end-to-end test suite in `cli_e2e.rs` to validate the functionality of the shipper CLI commands including `plan`, `doctor`, `status`, `preflight`, and `publish`.
- Implemented a fake cargo proxy to simulate cargo behavior during tests.
- Added fuzzing targets in the `fuzz` directory for `load_state` and `resolve_token` functions to enhance robustness against unexpected input.
- Created a `Cargo.toml` for the fuzzing setup, specifying dependencies and target binaries.
- Added EventLog struct for managing publish events.
- Implemented methods for recording, writing, and reading events from a JSONL file.
- Created tests for event logging functionality.

feat: add lock file mechanism to prevent concurrent publishes

- Introduced LockFile struct to manage locking.
- Implemented methods for acquiring, releasing, and checking lock status.
- Added functionality to handle stale locks with a timeout.
- Created tests for lock file operations.

feat: create state store abstraction for persistence

- Defined StateStore trait for various storage backends.
- Implemented FileStore for local filesystem storage of execution state, receipts, and event logs.
- Added methods for saving, loading, and clearing state.
- Created tests for FileStore functionality.

docs: add configuration file documentation

- Created documentation for .shipper.toml configuration file.
- Included sections for creating, validating, and using the configuration file.
- Documented configuration options for policy, verify mode, readiness, output, lock, retry, flags, and registry.
…ntation

- Introduced a new `[preflight]` section in configuration for ownership checks and new crate publishing settings.
- Added detailed documentation on preflight verification, including failure modes and troubleshooting steps.
- Implemented readiness checking to ensure published crates are visible on the registry before proceeding.
- Updated existing documentation to reflect changes in configuration options and added examples for clarity.
- Enhanced GitHub and GitLab CI templates to cache shipper state for faster builds.
- Created a new `environment.rs` module to collect environment fingerprint information for better diagnostics.
- Enhanced `validate_schema_version` function in `store.rs` to improve error context and readability.
- Updated `parse_schema_version` function for better clarity and error handling.
- Cleaned up whitespace and formatting in `store.rs` and `types.rs`.

Update documentation for configuration and preflight checks

- Changed CLI flag from `-o` to `-p` for specifying config files in `configuration.md`.
- Removed deprecated `[preflight]` section from configuration documentation.
- Added new `[parallel]` section for parallel publishing configuration.
- Updated readiness checks documentation to clarify configuration options.

Improve failure modes documentation

- Revised failure modes documentation to enhance clarity and detail.
- Updated examples and scenarios for better understanding of preflight outputs.

Add CLAUDE.md for developer guidance

- Created CLAUDE.md to provide build, test, and architecture guidance for contributors.

Release checklist for v0.2.0

- Added a new release checklist to ensure all pre-release tasks are completed.
- Included breaking changes notes for users upgrading from v0.1.0.

Add proptest regression seeds

- Introduced `types.txt` to store seeds for failure cases generated by proptest for consistent testing.
…diness checks

- Refactor `publish_package` to include detailed event logging for package publishing attempts, successes, and failures.
- Introduce `ReadinessEvidence` to capture visibility checks during publishing.
- Update `RegistryClient` to return readiness evidence with visibility checks.
- Implement a new `run_publish_parallel` function to manage parallel publishing across dependency levels.
- Add a CI workflow for automated checks, including formatting, linting, and testing.
- Improve error handling and reporting throughout the publishing process.
Convert CLI fields from default_value to Option<T> so we can
distinguish "user set this" from "clap filled default." Add
CliOverrides struct and build_runtime_options method that merges
config values when CLI is None (Option fields) and OR's booleans.
Registry config is applied to the plan after loading .shipper.toml.
Add PolicyEffects struct that translates PublishPolicy into concrete
decisions: Safe runs all checks, Balanced skips ownership, Fast skips
dry-run + ownership + readiness. Preflight and publish now consult
apply_policy() instead of reading opts flags directly for these
policy-affected behaviors.
Add cargo_publish_dry_run_package for per-package dry-runs. Preflight
now matches on VerifyMode: Workspace runs the existing workspace-level
dry-run, Package runs per-crate dry-runs with individual pass/fail
tracking, and None skips dry-run entirely (dry_run_passed = true).
Replace 8 .unwrap() calls on state map lookups in engine.rs with
.context()? to produce meaningful errors on corrupted state instead
of panicking. Remove the unused `package` field from
PackagePublishResult in engine_parallel.rs (the PackageReceipt
inside `result` already carries name and version).
Add test module with CollectingReporter, EnvGuard, mock HTTP server,
and fake cargo binaries for testing parallel publish logic. Tests
cover: skip already-published packages, successful publish with
readiness, permanent failure handling, transient retry behavior,
multi-package level processing, and update_state_locked helper.
Copilot AI review requested due to automatic review settings February 15, 2026 06:30
@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 Feb 15, 2026

Copy link
Copy Markdown

Summary by CodeRabbit

  • New Features

    • Introduced Shipper, a publishing reliability tool for Rust workspaces with multi-crate support
    • Added comprehensive CLI with commands for planning, preflight checks, publishing, resuming, status reporting, and configuration management
    • Implemented configuration system via .shipper.toml with CLI override support
    • Added state persistence and resumable publishing on failures
    • Integrated event logging for detailed publish lifecycle tracking
    • Implemented readiness verification with multiple detection methods
    • Added parallel publishing with configurable concurrency
    • Included CI/CD templates for GitHub Actions and GitLab
  • Documentation

    • Added configuration, preflight, readiness, and failure modes guides
    • Included developer documentation with architecture overview

Walkthrough

This pull request introduces Shipper, a comprehensive Rust project for deterministic, resumable multi-crate publishing. It establishes a workspace with two crates (shipper library and shipper-cli), complete with configuration systems, state management, event logging, preflight verification, and CI/CD templates alongside extensive documentation and fuzzing infrastructure.

Changes

Cohort / File(s) Summary
GitHub Configuration
.github/CODEOWNERS, .github/copilot-instructions.md, .github/settings.yml, .github/workflows/ci.yml
Adds repository ownership rules, Copilot guidance for the workspace structure and build workflows, repository metadata (description, homepage, topics), and a comprehensive CI pipeline that checks formatting, linting, and runs tests with warnings treated as errors.
Project Metadata & Licensing
Cargo.toml, LICENSE-MIT, LICENSE-APACHE, .gitignore
Establishes workspace configuration with two crates (shipper and shipper-cli), Rust 1.92 MSRV, v0.2.0 versioning, dual MIT/Apache-2.0 licensing, and project-specific ignore patterns (.shipper/, fuzz artifacts).
Documentation
README.md, CHANGELOG.md, RELEASE_NOTES_v0.2.0.md, RELEASE_CHECKLIST_v0.2.0.md, CLAUDE.md, GEMINI.md, docs/...
Comprehensive project documentation covering usage, features, architecture, configuration, preflight verification, readiness checking, failure handling, and upgrade guidance for v0.2.0 release.
Core Library (shipper)
crates/shipper/Cargo.toml, crates/shipper/src/types.rs, crates/shipper/src/lib.rs
Defines the domain model (ReleaseSpec, ReleasePlan, RuntimeOptions, PublishPolicy, ReadinessConfig, ParallelConfig, Receipt, ExecutionState, PublishEvent) with comprehensive serde support; establishes 14 public modules including configuration, state management, registry interaction, and publishing workflows.
Planning & Registry
crates/shipper/src/plan.rs, crates/shipper/src/registry.rs
Implements cargo metadata-based release planning with topological sorting and dependency graphs; provides RegistryClient for HTTP interaction with crate registries including ownership verification, sparse-index visibility checks, and backoff-based readiness verification with detailed evidence trails.
State & Persistence
crates/shipper/src/state.rs, crates/shipper/src/store.rs, crates/shipper/src/events.rs, crates/shipper/src/lock.rs
Manages state files (state.json, receipt.json, events.jsonl), receipt versioning with v1→v2 migration, event logging, file-based locking with stale-lock timeout handling, and StateStore trait abstraction for storage backends.
Core Publishing Logic
crates/shipper/src/engine.rs, crates/shipper/src/engine_parallel.rs
Implements preflight verification (git cleanliness, ownership, dry-runs, policy-driven behavior) and publish workflows with per-package state tracking, retry logic, registry visibility verification, and parallel package publishing with dependency-level sequencing.
Utilities
crates/shipper/src/auth.rs, crates/shipper/src/cargo.rs, crates/shipper/src/git.rs, crates/shipper/src/environment.rs, crates/shipper/src/config.rs
Token resolution from environment/credentials files; cargo command execution with output capture; git context collection (commit, branch, tag, dirty state); environment fingerprinting (versions, OS/arch); and comprehensive TOML-based configuration with CLI override merging.
CLI & Integration
crates/shipper-cli/Cargo.toml, crates/shipper-cli/src/main.rs, crates/shipper-cli/tests/cli_e2e.rs
Implements multi-command CLI (plan, preflight, publish, resume, status, doctor, inspect-events, inspect-receipt, ci, clean, config) with extensive option parsing, configuration loading and merging; includes 1000+ lines of end-to-end tests exercising planning, preflight, publishing flows, CI configuration generation, and snapshot-based output validation.
Fuzzing & Testing
fuzz/Cargo.toml, fuzz/fuzz_targets/load_state.rs, fuzz/fuzz_targets/resolve_token.rs, crates/shipper/proptest-regressions/types.txt
Adds property-based fuzzing harnesses for state loading and token resolution with tempdir isolation; includes proptest regression seeds for deterministic failure case replication.
CI/CD Templates
templates/github-trusted-publishing.yml, templates/gitlab-publish.yml
Provides GitHub Actions and GitLab CI templates for tag-triggered publishing using shipper-cli with resumable state caching and artifact preservation for cross-run consistency.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

This pull request introduces a complete, new project with substantial logic density across multiple interconnected modules (engine, registry, state management, config system). The heterogeneous nature—combining core publishing workflows, CLI dispatch, tests, documentation, and CI templates—requires careful review of each cohort's interactions, error handling patterns, and architectural soundness. The presence of 2500+ lines in engine.rs alone, 1200+ in engine_parallel.rs, 900+ in types.rs, and extensive test suites demands methodical evaluation of correctness, thread safety, and API design consistency.

Poem

🐰 A shipper's tale, in Rust so bold,
Publishing workflows, never to unfold!
From plan to publish, with grace and care,
Preflight checks and readiness in the air,
Events logged, state preserved with might—
Resumable publishes, always right! 🚀

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: implementing the complete shipper tool for reliable Rust workspace publishing. It is directly aligned with the primary objective of this PR.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, covering all major components (core library, CLI, tests, documentation) and explaining the implementation's scope and evidence of correctness.
Docstring Coverage ✅ Passed Docstring coverage is 91.12% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch initial-implementation

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.

Copilot AI 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.

Pull request overview

This PR implements shipper, a comprehensive publishing reliability layer for Rust workspaces. It addresses real-world challenges in multi-crate publishing including partial publishes, CI cancellations, registry backpressure, and ambiguous failures.

Changes:

  • Complete two-crate workspace: shipper (core library) and shipper-cli (CLI binary)
  • Publishing pipeline with plan → preflight → publish → resume workflow
  • State persistence, event logging, evidence capture, and readiness verification
  • Parallel publishing support, configurable policies, and comprehensive testing (280 tests)

Reviewed changes

Copilot reviewed 42 out of 44 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
Cargo.toml Workspace configuration with edition 2024, resolver 3, MSRV 1.92
crates/shipper/Cargo.toml Core library dependencies and metadata
crates/shipper-cli/Cargo.toml CLI binary dependencies and configuration
crates/shipper/src/*.rs Core modules: auth, plan, state, store, lock, events, environment, git, cargo, config
fuzz/ Fuzz targets for state loading and token resolution
docs/*.md Comprehensive documentation for configuration, preflight, readiness, failure modes
templates/*.yml CI templates for GitHub Actions and GitLab
LICENSE-*, README.md, CHANGELOG.md Project documentation and licensing
.github/workflows/ci.yml CI pipeline configuration

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +20 to +22
unsafe { std::env::set_var("CARGO_HOME", td.path()) };
unsafe { std::env::remove_var("CARGO_REGISTRY_TOKEN") };
unsafe { std::env::remove_var("CARGO_REGISTRIES_CRATES_IO_TOKEN") };

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

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

The unsafe block usage here is concerning given that the workspace has unsafe_code = "forbid" set in Cargo.toml. While this is in test code and uses env::set_var/env::remove_var which are marked unsafe for good reason (they can cause data races), consider using a safer alternative like the temp-env crate or ensuring tests run serially with proper guards. The EnvGuard pattern is good but the unsafe usage should be documented why it's necessary in tests.

Copilot uses AI. Check for mistakes.
Comment thread Cargo.toml
"crates/shipper",
"crates/shipper-cli",
]
resolver = "3"

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

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

The resolver version "3" is used, but this is experimental and requires Rust 1.83+ (nightly as of early 2024). However, the workspace specifies rust-version = "1.92" which is far in the future. Since your knowledge cutoff is January 2025 and it's now February 2026, resolver 3 may be stable. However, verify that resolver 3 is actually stable in Rust 1.92 and that this MSRV is achievable. If not, this could cause build failures for users.

Suggested change
resolver = "3"
resolver = "2"

Copilot uses AI. Check for mistakes.
Comment on lines +157 to +177
unsafe { env::set_var(key, value) };
Self {
key: key.to_string(),
old,
}
}

fn unset(key: &str) -> Self {
let old = env::var(key).ok();
unsafe { env::remove_var(key) };
Self {
key: key.to_string(),
old,
}
}
}

impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(v) = &self.old {
unsafe { env::set_var(&self.key, v) };

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

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

The unsafe blocks used here violate the workspace-level unsafe_code = "forbid" lint. While these are in test code using env::set_var/env::remove_var, which are inherently unsafe due to potential data races, the pattern needs documentation. Consider either: 1) Using the serial_test crate (which is already in dependencies) to ensure serial execution, 2) Documenting why unsafe is necessary in tests, or 3) Using a test-specific crate like temp-env that provides a safer abstraction.

Copilot uses AI. Check for mistakes.

@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: dd278495e3

ℹ️ 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 +213 to +214
let verify_result =
reg.is_version_visible_with_backoff(&p.name, &p.version, &opts.readiness);

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 Honor fast policy during parallel publish verification

In parallel mode, successful publishes always call readiness verification with &opts.readiness, which bypasses the policy gating used in the sequential path. PublishPolicy::Fast is supposed to disable verification, but shipper publish --policy fast --parallel can still wait for visibility and fail on readiness timeouts, so parallel behavior diverges from the selected policy.

Useful? React with 👍 / 👎.

Comment on lines +162 to +165
if effects.strict_ownership {
// In strict mode, ownership errors are fatal
reg.list_owners(&p.name, token.as_deref().unwrap())?;
true

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 Skip strict ownership checks for crates that do not exist yet

Preflight computes is_new_crate but the strict-ownership branch still unconditionally calls list_owners; for crates not yet created, that endpoint is treated as an error and aborts preflight. This makes --strict-ownership fail for intentional first-time crate publishes, even when everything else is valid, so strict mode cannot be used for new crates.

Useful? React with 👍 / 👎.

Comment on lines +184 to +185
let versions: Vec<IndexVersion> = serde_json::from_str(content)
.with_context(|| format!("failed to parse index JSON for version {}", 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 Parse sparse index content as line-delimited entries

The index parser expects the entire sparse-index file to be a single JSON array (Vec<IndexVersion>), but sparse index files are line-delimited JSON records. That causes parsing to fail on normal index responses, and check_index_visibility then degrades to false, so --readiness-method index can repeatedly miss already-published versions and time out.

Useful? React with 👍 / 👎.

Comment on lines +157 to +164
let out = match cargo::cargo_publish(
&ws.workspace_root,
&p.name,
&ws.plan.registry.name,
opts.allow_dirty,
opts.no_verify,
opts.output_lines,
) {

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 Enforce per-package timeout in parallel workers

Parallel workers invoke cargo::cargo_publish directly with no timeout guard, and this path never checks opts.parallel.per_package_timeout. If a publish process hangs, the worker can block indefinitely despite the exposed timeout configuration, so the documented safety control is effectively a no-op.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai 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.

Actionable comments posted: 44

🤖 Fix all issues with AI agents
In @.github/CODEOWNERS:
- Around line 1-2: Update the .github/CODEOWNERS file to broaden review coverage
by adding rules for the important paths referenced in the comment: add entries
for .github/workflows/ (e.g., ".github/workflows/ `@YourTeam`"), the workspace
Cargo.toml (e.g., "Cargo.toml `@YourTeam`"), the crates/ directory (e.g., "crates/
`@YourTeam`"), and a catch-all fallback (e.g., "* `@YourTeam`") so PRs touching
those symbols get auto-requested reviewers; edit the CODEOWNERS file to include
these additional path patterns and appropriate owner handles.

In @.github/copilot-instructions.md:
- Line 23: Replace the Windows-style backslash path in the Cargo install command
so it matches repository conventions: change the string `crates\\shipper-cli` to
use forward slashes (i.e., `crates/shipper-cli`) in the line containing `cargo
install --path crates\\shipper-cli --locked` so the command is consistent and
cross-platform.

In @.github/workflows/ci.yml:
- Around line 12-45: The CI currently runs all verification steps inside one job
named "check" so an early failure in the "Format check" step blocks "Clippy" and
"Tests"; split the monolithic job into separate jobs (e.g., "fmt" for the Format
check, "clippy" for the Clippy step, "tests" for the Tests step, and
"release-build" for Release build) so they can run in parallel and provide
faster feedback, preserve shared setup (Rust install and actions/cache) in each
job or move common setup to a reusable workflow/step to avoid duplication, and
make any dependent job (e.g., "release-build") explicitly depend on "tests"
using needs to keep serial ordering where required.
- Around line 3-6: Add a GitHub Actions concurrency group to the workflow to
cancel superseded runs: insert a top-level concurrency block (using a group name
based on the workflow and branch/PR ref, e.g. referencing github.workflow and
github.head_ref or github.ref) and set cancel-in-progress: true so when multiple
commits trigger the existing on: push / pull_request workflow older runs are
cancelled automatically.

In `@CHANGELOG.md`:
- Around line 1-105: Add repository comparison links for each released version
at the bottom of CHANGELOG.md so version headers link to diffs; add lines like
"[0.2.0]: https://github.com/<ORG>/<REPO>/compare/v0.1.0...v0.2.0" and "[0.1.0]:
https://github.com/<ORG>/<REPO>/releases/tag/v0.1.0" replacing <ORG>/<REPO> and
prior-tag names as appropriate, ensuring the identifiers exactly match the
version headers "[0.2.0]" and "[0.1.0]" so Keep a Changelog tools will resolve
them.

In `@crates/shipper-cli/Cargo.toml`:
- Around line 1-32: Add a [lints] table to the shipper-cli crate's Cargo.toml to
opt into inheriting workspace lints (same pattern used in the shipper crate) so
workspace lint settings are applied to this crate; update the Cargo.toml for the
package named "shipper-cli" to include the [lints] section with inherit = true.

In `@crates/shipper-cli/src/main.rs`:
- Around line 1096-1151: The test run_doctor_supports_absolute_state_dir mutates
CARGO_REGISTRY_TOKEN, CARGO_REGISTRIES_CRATES_IO_TOKEN, and CARGO_HOME before
capturing their "old" values, so restore_env restores the test values instead of
originals; fix by saving the originals (call env::var(...).ok() into
old_registry, old_named, old_home) before any env::set_var/remove_var calls, or
better replace the manual save/restore with an EnvGuard RAII (as used in
auth.rs) that records and restores those three vars around the run_doctor call;
update references in this test to use EnvGuard or reorder the save/set so
restore_env receives the true pre-test values.
- Line 896: Summary: Change run_clean's state_dir parameter from &PathBuf to
&Path to satisfy clippy::ptr_arg. Update the function signature fn
run_clean(state_dir: &Path, workspace_root: &Path, keep_receipt: bool) ->
Result<()> and adjust any internal code that assumed a PathBuf (use
state_dir.as_ref() or state_dir.to_path_buf() only where an owned PathBuf is
truly needed). Also update all call sites that currently pass a &PathBuf to pass
a &Path (e.g., by borrowing the PathBuf as &*buf or using buf.as_path()), and
run cargo clippy to ensure the ptr_arg warning is resolved.
- Around line 455-463: The printed finishability line sets ANSI color via
finishability_color but never resets it, causing color bleed; update the println
that uses rep.finishability / finishability_color / finishability_text to emit
the ANSI reset sequence ("\x1b[0m") after the text (either append the reset to
the selected color string or include it in the println format) so the terminal
color is restored immediately after the finishability text.

In `@crates/shipper-cli/tests/cli_e2e.rs`:
- Around line 302-314: The manual ANSI-stripping folded into the computation of
`stripped` from `normalized` is fragile and should be replaced with a tested
crate function; update the test to call a library like strip-ansi-escapes or
console::strip_ansi_codes on `normalized` instead of the custom fold, add the
chosen crate as a dev-dependency, and assign its result to `stripped` so all
ANSI sequences (not just SGR `\x1b[...m`) are correctly removed.
- Around line 129-149: spawn_registry currently calls server.recv() in the
spawned thread and will block forever (causing registry.join() to hang) if fewer
requests arrive than expected; replace server.recv() with a timed receive (e.g.,
server.recv_timeout(Duration::from_secs(10))) inside the thread loop in
spawn_registry, handle the timeout/error result by returning/panic with a clear
message or breaking the loop so the thread terminates cleanly, and keep the
existing logic that selects status from statuses (refer to spawn_registry,
server.recv(), and TestRegistry.handle) so tests fail fast instead of
deadlocking.

In `@crates/shipper/Cargo.toml`:
- Around line 1-36: Add a [lints] table to this crate's Cargo.toml to inherit
workspace lints by adding a section like `[lints] workspace = true`; this will
pull in the root workspace lints (e.g. the `workspace.lints.rust` settings such
as `unsafe_code = "forbid"`) so the crate respects the workspace lint
configuration.

In `@crates/shipper/src/auth.rs`:
- Around line 58-65: cargo_home_dir currently checks CARGO_HOME then HOME and
errors, which fails on Windows where HOME is often unset; update cargo_home_dir
to also try env::var("USERPROFILE") and, if that is unset, fall back to
combining env::var("HOMEDRIVE") and env::var("HOMEPATH") before returning an
error, and ensure the returned PathBuf uses the chosen value joined with
".cargo" and that the context/error message reflects the additional fallbacks;
refer to the cargo_home_dir function and its env::var calls when making the
change.
- Around line 108-123: The alias lookup loop inside the registry_name ==
"crates-io" branch redundantly includes "crates-io" again; update the loop in
that block (the for alt in [...] that checks registries.{alt}.token) to remove
the "crates-io" entry so it only iterates over the true aliases ("crates.io",
"crates_io"), leaving the explicit crates-io lookup earlier intact.
- Line 67: Change the token_from_credentials_file signature to accept a &Path
instead of &PathBuf: replace the parameter type in fn
token_from_credentials_file(path: &PathBuf, registry_name: &str) ->
Result<Option<String>> with &Path and update any internal uses (e.g.,
path.exists(), path.join(), file opening) to work with &Path; also update call
sites to pass path.as_ref() or a &Path directly and add use std::path::Path if
needed so the function accepts a more general path reference and satisfies
clippy::ptr_arg.

In `@crates/shipper/src/config.rs`:
- Around line 256-303: The validate() method currently misses checks for
readiness backoff timing which can lead to tight CPU spin; add validation inside
Config::validate (or the validate method shown) to ensure readiness.max_delay >
0 and readiness.initial_delay > 0, and also enforce that readiness.initial_delay
<= readiness.max_delay and readiness.initial_delay <= readiness.max_total_wait
(or at minimum initial_delay <= max_total_wait) and bail with clear messages
(e.g., "readiness.max_delay must be greater than 0", "readiness.initial_delay
must be greater than 0", "readiness.initial_delay must be <=
readiness.max_delay", "readiness.initial_delay must be <=
readiness.max_total_wait") so the readiness backoff cannot be zero or cause
tight loops.

In `@crates/shipper/src/engine_parallel.rs`:
- Around line 76-77: The calls to state::save_state in publish_package are
currently silencing errors with "let _ = …"; change each to capture the Result
and on Err call the reporter to log the failure (e.g. match
state::save_state(state_dir, &state) { Ok(_) => {}, Err(e) =>
reporter.error(&format!("failed to save state to {}: {}", state_dir.display(),
e)) }) so persistence failures are visible; apply the same change at every site
where state::save_state is ignored (the calls inside publish_package shown in
the diff) and include the state_dir and the error details in the log message.
- Around line 578-590: Currently SendReporter buffers all messages and replays
them to the real reporter only at the end (the block that locks send_reporter
and iterates sr.infos/sr.warns/sr.errors), which defers all output until the
entire parallel publish finishes; change the code so the same replay logic runs
after each level completes (i.e., after each level's work loop/worker join)
instead of once at the end: locate where levels are processed in
engine_parallel.rs, and after the per-level completion point call the existing
replay code (lock send_reporter, iterate sr.infos/sr.warns/sr.errors and call
reporter.info/warn/error) to flush that level’s messages; alternatively
implement a channel-based forwarder between SendReporter and reporter if you
prefer streaming, but the minimal fix is moving the replay block to run after
every level completion.
- Around line 538-559: SendReporter currently nests Mutexes (infos/warns/errors:
Mutex<Vec<String>>) while the instance is already stored in
Arc<Mutex<SendReporter>> (send_reporter), so remove the inner mutexes: change
SendReporter fields to plain Vec<String>, update the Reporter impl methods
(info/warn/error) to push directly onto the vectors via &mut self (no .lock()),
and adjust the send_reporter construction to initialize Vec::new() for each
field instead of Mutex::new(...). Ensure all usages that previously relied on
inner locks now acquire the outer Mutex (e.g., via send_reporter.lock()) and
mutate the vectors through &mut SendReporter.

In `@crates/shipper/src/engine.rs`:
- Around line 437-597: The loop currently retries when cargo publish exits
successfully but verify_published returns not visible, causing a re-publish;
instead, after a successful cargo publish (the success branch where
out.exit_code == 0), if verify_published reports not visible, mark the package
as Published and exit the loop rather than retrying. Concretely, inside the
success branch after verify_published (the block handling visible == false),
call update_state(&mut st, &state_dir, &key, PackageState::Published)?, clear
last_err, record the PackagePublished PublishEvent and write/clear event_log
(same as the visible==true path), set readiness_evidence appropriately, then
break; ensure you reference verify_published, update_state,
PackageState::Published, event_log.record/ write_to_file/clear, and
readiness_evidence when making the change.

In `@crates/shipper/src/environment.rs`:
- Around line 162-175: The current test
environment_fingerprint_shipper_version_is_set uses a naive char check on
fingerprint.shipper_version which wrongly rejects valid pre-release/build
metadata and accepts malformed strings; update the assertion to validate using a
real semver parser (e.g. semver::Version::parse) or a stricter pattern: call
collect_environment_fingerprint(), get fingerprint.shipper_version and either
assert semver::Version::parse(&fingerprint.shipper_version).is_ok() or assert it
starts with a digit and contains at least one '.' to perform a basic sanity
check (replace the chars().all(...) check accordingly).
- Around line 30-61: Both get_cargo_version and get_rust_version duplicate the
same logic; extract a helper fn get_tool_version(cmd: &str) -> Option<String>
that runs Command::new(cmd).arg("--version").output().ok()?, checks
output.status.success(), reads stdout via String::from_utf8_lossy, and returns
the second whitespace token (.split_whitespace().nth(1).map(|s| s.to_string())).
Then implement get_cargo_version() -> get_tool_version("cargo") and
get_rust_version() -> get_tool_version("rustc") to remove the duplicated parsing
logic.

In `@crates/shipper/src/events.rs`:
- Around line 28-51: write_to_file currently appends the entire in-memory events
Vec (self.events) each call, causing duplicate records unless callers call
clear(); change write_to_file to adopt a flush semantic: after successfully
serializing/writing all events and writer.flush() succeeds, clear the written
events (e.g., call the existing clear() or self.events.clear()) so subsequent
calls only append new events; ensure errors still return without clearing and
add/update tests for write_to_file to verify that a successful write removes
events from memory while failure preserves them.

In `@crates/shipper/src/git.rs`:
- Around line 82-97: The get_git_tag function is incorrectly passing the shell
redirection string "2>/dev/null" as an argument to git; remove the
.arg("2>/dev/null") call so
Command::new(git_program()).arg("describe").arg("--tags").arg("--exact-match").current_dir(repo_root).output()
is used instead, allowing the code to rely on output.status and .ok() to handle
failures; update the call site in get_git_tag accordingly and ensure behavior
(returning None on non-success) remains unchanged.
- Around line 156-180: The EnvGuard test helper is duplicated across five files
(variants A and B); extract it into a single shared #[cfg(test)] test utility
module (e.g., tests/util or a new crate::test_utils mod) and replace the local
copies with imports; implement one unified EnvGuard struct that provides both
set(&str,&str) and unset(&str) methods and derives Clone so it covers both
variants, and ensure Drop restores or removes the env var as before; update
usages in git.rs, engine.rs, engine_parallel.rs, auth.rs, and cargo.rs to use
the shared EnvGuard.
- Around line 11-13: Change collect_git_context to accept repo_root: &Path
instead of calling std::env::current_dir(); replace the current_dir lookup with
using the passed repo_root when constructing repo paths and invoking git
operations inside collect_git_context; update all callers (e.g., the call from
engine where workspace_root is available) to pass their
repo_root/workspace_root, and adjust tests to pass a temporary Path to avoid
mutating CWD so behavior remains consistent with is_git_clean and
ensure_git_clean.

In `@crates/shipper/src/lock.rs`:
- Around line 32-35: LockFile currently defines a dead field `file:
Option<File>` that is always left as None by `LockFile::acquire` and
`LockFile::release`; either remove the field entirely from the `LockFile` struct
and delete any code in `acquire`/`release` that assigns to `self.file`, or if
the intent was to hold the opened file for an advisory lock, change `acquire` to
store the opened File in `self.file = Some(file)` and ensure `release` drops it
(sets to None) and that ownership/locking semantics are correct; update
references in `acquire`, `release`, and any constructors accordingly to match
the chosen approach.
- Around line 43-93: The acquire function currently uses a check-then-create
pattern (lock_path.exists() then create tmp + rename) which allows a TOCTOU
race; change it to create the lock file atomically using OS semantics instead of
the exists() check: remove the preliminary exists() branch and instead attempt
to open/create the lock file atomically (e.g., use
std::fs::OpenOptions::new().create_new(true).write(true).open(&lock_path) or use
fs2::FileExt::try_lock_exclusive on a permanently opened file) and on success
write the serialized LockInfo into that file (or write to a temp and rename only
if you still need content-atomicity, but creation must use create_new or an
exclusive lock). Ensure acquire returns the Lock struct with the held file
handle so the process retains the lock until drop, and map any create_new/open
failures to the existing "lock already held" error path referencing acquire(),
lock_path, and tmp_path as needed.

In `@crates/shipper/src/plan.rs`:
- Around line 283-334: group_by_levels currently assumes self.packages is
topologically ordered which makes level assignment fragile; rewrite
ReleasePlan::group_by_levels to derive levels from self.dependencies in an
order‑independent way (e.g. Kahn's algorithm): build the graph and indegree map
from self.dependencies and all package names, enqueue nodes with indegree 0 as
level 0, pop each node, assign its level into a pkg_level map, then for each
dependent decrement indegree and set dependent_level = max(dependent_level,
current_level + 1) before enqueueing when indegree hits 0; finally collect
PublishLevel structs from the pkg_level map into a Vec<PublishLevel> (ensuring
PublishLevel.level values are consistent) instead of relying on the iteration
over self.packages or the current while/levels logic.
- Around line 64-139: When building dependency edges in the loop over
resolve.nodes (the block that currently fills deps_of/dependents_of), detect if
a publishable package depends on a workspace member that is not in publishable:
for each dep in node.deps (and where dep_kinds contains Normal/Build) if
pkg_map.contains_key(&dep.pkg) && !publishable.contains(&dep.pkg) then return an
error (or otherwise reject/skip) with a clear message naming the publishable
package (node.id or pkg_map.get(&node.id).name) and the non-publishable
workspace dependency (dep.pkg or its name). Add this check before silently
continuing so such invalid publishable crates are reported rather than dropping
the edge.

In `@crates/shipper/src/registry.rs`:
- Around line 133-188: calculate_index_path currently always uses a 2+2+N layout
and parse_version_from_index treats the index as a JSON array; fix both to
follow Cargo's sparse index spec: update calculate_index_path to return
"1/<name>" for len==1, "2/<name>" for len==2, "3/<first>/<name>" for len==3, and
"<first_two>/<second_two>/<name>" for len>=4 (use the first two and next two
characters of crate_name), and update parse_version_from_index to parse
newline-delimited JSON (JSONL) by iterating each non-empty line, deserializing
into the IndexVersion struct and checking vers == version (instead of parsing
the entire content as an array); keep the existing IndexVersion struct and the
same error context when deserialization of a line fails.

In `@crates/shipper/src/state.rs`:
- Around line 199-219: The function atomic_write_json performs fsync on the temp
file but does not fsync the parent directory after fs::rename, so the rename may
be lost on crash; modify atomic_write_json to, after fs::rename(&tmp, path)
succeeds, open the parent directory (using path.parent()) as a File (via
OpenOptions::new().read(true).open(parent_dir) or similar) and call sync_all()
on that directory File, propagating errors with context (e.g., "failed to sync
parent directory ..."); handle the case where path.parent() is None
appropriately and include with_context messages consistent with existing error
handling.

In `@crates/shipper/src/store.rs`:
- Around line 72-83: The check in parse_schema_version is too permissive and the
middle segment isn't validated; change the prefix check from
parts[0].starts_with("shipper") to an exact equality check parts[0] == "shipper"
and add validation for parts[1] (the schema type) — either assert it matches
allowed values like "receipt" or "state" or change parse_schema_version to
return the middle segment alongside the numeric version so callers can enforce
type consistency (update callers such as validate_schema_version accordingly).

In `@crates/shipper/src/types.rs`:
- Around line 57-66: The current get_index_base method uses naive string
replacement on api_base which breaks for hosts with paths or already-indexed
hosts; update get_index_base to parse self.api_base as a URL (e.g., using
url::Url), then construct the index base by prefixing "index." to the parsed
host (unless the host already contains "index." or an explicit self.index_base
is set) and preserve the scheme and any port but omit path/segments, falling
back to returning the original self.index_base.clone() when provided; ensure
get_index_base handles parse errors by falling back to a sensible default
(original api_base or self.index_base) and document the behavior.

In `@docs/preflight.md`:
- Around line 61-90: Add the language specifier "text" to the fenced code blocks
used for sample output: change the triple-backtick fences that wrap the
"Preflight Report" block (the block starting with "Preflight Report" and showing
Plan ID, Token Detected, Packages table, Summary, and "What to do next:"), and
the other similar fenced blocks later in the file that show sample CLI/output
examples, to use ```text so markdown linters/renderers recognize them as plain
text output.

In `@docs/readiness.md`:
- Around line 184-216: Consolidate the duplicate troubleshooting content by
merging the "Index checks are slow" section into "Readiness checks are slow" to
eliminate redundancy: keep a single "Readiness checks are slow" heading (and
mention index-specific note inside it) that contains the shared causes and
solutions (recommend `--readiness-method api`, `--readiness-poll`,
`--readiness-timeout`, and local index mirror), remove the separate "Index
checks are slow" block, update any cross-references/TOC entries to point to the
unified section, and ensure the "Readiness checks fail even though crate was
published" section still references both methods/timeout as before.
- Around line 157-168: The fenced pseudocode blocks that show retry delays are
missing language specifiers; update both code fences that contain the "Attempt
1: delay = initial_delay" block and the "actual_delay = delay * (1 ±
jitter_factor)" line to include a language tag (use "text") so the fences become
```text ... ``` to satisfy markdownlint MD040.

In `@fuzz/fuzz_targets/resolve_token.rs`:
- Around line 20-24: Save the original environment state before mutating it and
restore after the call to resolve_token("crates-io") by adopting the EnvGuard
pattern used elsewhere: capture current values of CARGO_HOME,
CARGO_REGISTRY_TOKEN and CARGO_REGISTRIES_CRATES_IO_TOKEN, set/remove them for
the fuzz iteration, call resolve_token, then restore the saved values (or remove
if previously absent) in a drop/guard so restoration happens even on panic;
locate this logic around the resolve_token invocation in resolve_token.rs and
mirror the EnvGuard behavior from the EnvGuard implementation used in
git.rs/engine.rs.

In `@GEMINI.md`:
- Line 40: Remove the trailing space after the bold heading "**Testing
Pattern:**" to satisfy MD009; edit the line containing "**Testing Pattern:** "
(the bold heading) and delete the extra space at the end (or intentionally
replace with two spaces only if you want a hard line break), ensuring no other
trailing whitespace remains on that line.

In `@LICENSE-MIT`:
- Line 3: Update the MIT license header to include the copyright holder name by
replacing the current "Copyright (c) 2026" line with a complete notice that
includes the holder (for example: "Copyright (c) 2026 EffortlessMetrics" or the
project's author/organization name); ensure the holder name is the official
entity owning the code and matches other project metadata.

In `@RELEASE_CHECKLIST_v0.2.0.md`:
- Around line 5-16: Update the inconsistent test counts in the release checklist
by changing the numbers in the top checklist and the "Code Quality Checks" block
to match the PR summary (replace "229 lib + 13 CLI unit + 20 E2E" and "262
total: 229 lib, 13 CLI unit, 20 CLI E2E" with the PR-reported counts, e.g., "247
lib" and the correct total "280 total" and adjust the breakdown accordingly);
edit the lines that contain the literal backticked commands/labels "Run `cargo
test --workspace` — all passing (229 lib + 13 CLI unit + 20 E2E)" and the "All
tests pass (262 total: 229 lib, 13 CLI unit, 20 CLI E2E)" text to the updated
numbers so the checklist and Code Quality Checks section are consistent.
- Around line 63-67: The checklist item "- [ ] Publish to crates.io:" contains a
fenced code block (```bash ... cargo publish -p shipper-cli ``` ) that lacks
blank lines before and after the block; update the Markdown to add a blank line
between the list item text and the opening ```bash, and another blank line after
the closing ``` so the fenced code block is separated from surrounding list
items (ensure the opening ```bash and closing ``` remain intact and the two
cargo publish lines are unchanged).

In `@templates/github-trusted-publishing.yml`:
- Line 30: Update the workflow steps that reference actions/cache and
actions/upload-artifact to use the v4 releases to match the other v4 actions;
specifically replace uses: actions/cache@v3 with actions/cache@v4 and uses:
actions/upload-artifact@v3 with actions/upload-artifact@v4, then commit and
validate the workflow syntax by running the GitHub Actions linter or a test run
to ensure compatibility with actions/checkout@v4 and
actions/download-artifact@v4.

In `@templates/gitlab-publish.yml`:
- Line 5: Update the Docker image tag used in the CI template: replace the
hardcoded image reference "image: rust:1.75" with the project's MSRV "rust:1.92"
so the job uses Rust 1.92 (locate the `image:` line in the gitlab CI template
where `rust:1.75` is declared and change it to `rust:1.92`).

Comment thread .github/CODEOWNERS
Comment on lines +1 to +2
# .github/CODEOWNERS
/.github/settings.yml @EffortlessSteven

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider broadening CODEOWNERS coverage.

Only .github/settings.yml is covered. Critical paths like .github/workflows/, crates/, and the workspace Cargo.toml have no designated owner, so PRs touching those paths won't auto-request reviews.

A catch-all or additional rules would ensure review coverage:

 # .github/CODEOWNERS
 /.github/settings.yml `@EffortlessSteven`
+* `@EffortlessSteven`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# .github/CODEOWNERS
/.github/settings.yml @EffortlessSteven
# .github/CODEOWNERS
/.github/settings.yml `@EffortlessSteven`
* `@EffortlessSteven`
🤖 Prompt for AI Agents
In @.github/CODEOWNERS around lines 1 - 2, Update the .github/CODEOWNERS file to
broaden review coverage by adding rules for the important paths referenced in
the comment: add entries for .github/workflows/ (e.g., ".github/workflows/
`@YourTeam`"), the workspace Cargo.toml (e.g., "Cargo.toml `@YourTeam`"), the
crates/ directory (e.g., "crates/ `@YourTeam`"), and a catch-all fallback (e.g.,
"* `@YourTeam`") so PRs touching those symbols get auto-requested reviewers; edit
the CODEOWNERS file to include these additional path patterns and appropriate
owner handles.

- Build (release):
- `cargo build --release`
- Install the CLI locally (recommended for manual testing):
- `cargo install --path crates\\shipper-cli --locked`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Windows-style backslash in path — use forward slash for consistency.

Line 23 uses crates\\shipper-cli while every other path in this file and the repository uses forward slashes. Forward slashes work on all platforms including Windows with Cargo.

Proposed fix
-- `cargo install --path crates\\shipper-cli --locked`
+- `cargo install --path crates/shipper-cli --locked`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `cargo install --path crates\\shipper-cli --locked`
- `cargo install --path crates/shipper-cli --locked`
🤖 Prompt for AI Agents
In @.github/copilot-instructions.md at line 23, Replace the Windows-style
backslash path in the Cargo install command so it matches repository
conventions: change the string `crates\\shipper-cli` to use forward slashes
(i.e., `crates/shipper-cli`) in the line containing `cargo install --path
crates\\shipper-cli --locked` so the command is consistent and cross-platform.

Comment thread .github/workflows/ci.yml
Comment on lines +3 to +6
on:
push:
branches: [main]
pull_request:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding concurrency control to cancel superseded workflow runs.

Without a concurrency group, pushing multiple commits to a PR branch will run all CI jobs in parallel, wasting resources.

Proposed fix
 on:
   push:
     branches: [main]
   pull_request:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
on:
push:
branches: [main]
pull_request:
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
🤖 Prompt for AI Agents
In @.github/workflows/ci.yml around lines 3 - 6, Add a GitHub Actions
concurrency group to the workflow to cancel superseded runs: insert a top-level
concurrency block (using a group name based on the workflow and branch/PR ref,
e.g. referencing github.workflow and github.head_ref or github.ref) and set
cancel-in-progress: true so when multiple commits trigger the existing on: push
/ pull_request workflow older runs are cancelled automatically.

Comment thread .github/workflows/ci.yml
Comment on lines +12 to +45
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Cache cargo registry & build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-

- name: Format check
run: cargo fmt --all -- --check

- name: Clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings

- name: Tests
run: cargo test --workspace
timeout-minutes: 30

- name: Release build
run: cargo build --release

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

All CI steps run in a single job — a failure in fmt blocks feedback from clippy/test.

This is a common tradeoff (simplicity vs. fast feedback). If you want faster developer feedback, consider splitting fmt/clippy/test into parallel jobs. Not blocking, just a consideration as the project grows.

🤖 Prompt for AI Agents
In @.github/workflows/ci.yml around lines 12 - 45, The CI currently runs all
verification steps inside one job named "check" so an early failure in the
"Format check" step blocks "Clippy" and "Tests"; split the monolithic job into
separate jobs (e.g., "fmt" for the Format check, "clippy" for the Clippy step,
"tests" for the Tests step, and "release-build" for Release build) so they can
run in parallel and provide faster feedback, preserve shared setup (Rust install
and actions/cache) in each job or move common setup to a reusable workflow/step
to avoid duplication, and make any dependent job (e.g., "release-build")
explicitly depend on "tests" using needs to keep serial ordering where required.

Comment thread CHANGELOG.md
Comment on lines +1 to +105
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-02-14

### Added

#### Four Pillars of Publishing Reliability

- **Evidence Capture**: Every publish operation now captures detailed evidence including stdout, stderr, exit codes, and timestamps for debugging and auditing purposes.
- **Event Logging**: Comprehensive event log (`events.jsonl`) records every step of the publishing process with timestamps for complete audit trails.
- **Readiness Checks**: Configurable readiness verification ensures published crates are actually available on the registry before proceeding.
- **Publish Policies**: Three built-in policies control verification behavior (safe, balanced, fast) allowing users to choose the right balance of safety and speed.

#### New CLI Commands

- `shipper inspect-events` - View detailed event log with timestamps and evidence
- `shipper inspect-receipt` - View detailed receipt with captured evidence
- `shipper ci github-actions` - Print GitHub Actions workflow snippet
- `shipper ci gitlab` - Print GitLab CI workflow snippet
- `shipper clean` - Clean state files (state.json, receipt.json, events.jsonl)
- `shipper config init` - Generate a default `.shipper.toml` configuration file
- `shipper config validate` - Validate a configuration file

#### New CLI Flags

- `--config <path>` - Path to a custom `.shipper.toml` configuration file
- `--policy <policy>` - Publish policy: safe (verify+strict), balanced (verify when needed), fast (no verify)
- `--verify-mode <mode>` - Verify mode: workspace (default), package (per-crate), none (no verify)
- `--readiness-method <method>` - Readiness check method: api (default, fast), index (slower, more accurate), both (slowest, most reliable)
- `--readiness-timeout <duration>` - How long to wait for registry visibility during readiness checks (default: 5m)
- `--readiness-poll <duration>` - Poll interval for readiness checks (default: 2s)
- `--no-readiness` - Disable readiness checks (for advanced users)
- `--output-lines <number>` - Number of output lines to capture for evidence (default: 50)
- `--format <format>` - Output format: text (default) or json
- `--force` - Force override of existing locks (use with caution)
- `--lock-timeout <duration>` - Lock timeout duration (default: 1h)

#### New State Files

- `events.jsonl` - Line-delimited JSON event log for debugging and auditing

#### New Features

- Configuration file support (`.shipper.toml`) with `config init` and `config validate` subcommands
- Lock file mechanism to prevent concurrent publish operations
- Configurable evidence capture with adjustable output line limits
- JSON output format for CI/CD integration
- Readiness verification with multiple methods (API, index, combined)
- Publish policies for different safety levels
- Enhanced receipt format with embedded evidence
- Schema versioning for state, plan, and receipt files

#### Parallel Publishing

- **Parallel publishing**: Packages at the same dependency level can now be published concurrently with `--parallel`
- New CLI flags: `--parallel`, `--max-concurrent <N>`, `--per-package-timeout <duration>`
- Configurable via `[parallel]` section in `.shipper.toml`

### Changed

- Improved error messages with context and evidence references
- Enhanced state file format with additional metadata
- Better handling of registry API rate limits
- Improved retry logic with exponential backoff and jitter

### Fixed

- Fixed potential race conditions in state file handling
- Improved handling of ambiguous failures where upload may have succeeded
- Better error recovery for network timeouts
- Fixed issues with resume when workspace configuration changes

### Breaking Changes

- The state file format has changed. Previous versions of shipper cannot resume from v0.2 state files.
- The receipt file format has been enhanced with additional evidence fields.
- Default readiness timeout increased from 2m to 5m for more reliable verification.

### Migration Guide from v0.1.0

If you're upgrading from v0.1.0:

1. **Clean old state files**: Run `shipper clean` before upgrading to remove old state files.
2. **Update CI workflows**: The new `shipper ci` command can generate updated workflow snippets.
3. **Review readiness settings**: The default readiness timeout has increased; adjust if needed.
4. **Test publish policies**: Try the different policy modes to find the best fit for your workflow.

## [0.1.0] - 2025-01-15

### Added

- Initial release
- Basic publish planning and execution
- Preflight checks (git cleanliness, publishability, registry reachability)
- Optional ownership/permissions verification
- Retry/backoff for retryable failures
- Registry API verification before declaring success
- Resumable execution with state persistence
- Status command to compare local versions to registry
- Doctor command for environment and auth diagnostics

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding version comparison links at the bottom of the changelog.

Per the Keep a Changelog convention, version headers should link to diff comparisons. For example:

[0.2.0]: https://github.com/EffortlessMetrics/shipper/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/EffortlessMetrics/shipper/releases/tag/v0.1.0
🧰 Tools
🪛 LanguageTool

[typographical] ~8-~8: To join two clauses or introduce examples, consider using an em dash.
Context: ...emver.org/spec/v2.0.0.html). ## [0.2.0] - 2026-02-14 ### Added #### Four Pillars...

(DASH_RULE)


[typographical] ~21-~21: To join two clauses or introduce examples, consider using an em dash.
Context: ...CLI Commands - shipper inspect-events - View detailed event log with timestamps ...

(DASH_RULE)


[typographical] ~31-~31: To join two clauses or introduce examples, consider using an em dash.
Context: ... #### New CLI Flags - --config <path> - Path to a custom .shipper.toml configu...

(DASH_RULE)


[typographical] ~32-~32: To join two clauses or introduce examples, consider using an em dash.
Context: ...configuration file - --policy <policy> - Publish policy: safe (verify+strict), ba...

(DASH_RULE)


[typographical] ~33-~33: To join two clauses or introduce examples, consider using an em dash.
Context: ...ast (no verify) - --verify-mode <mode> - Verify mode: workspace (default), packag...

(DASH_RULE)


[typographical] ~34-~34: To join two clauses or introduce examples, consider using an em dash.
Context: ... verify) - --readiness-method <method> - Readiness check method: api (default, fa...

(DASH_RULE)


[typographical] ~35-~35: To join two clauses or introduce examples, consider using an em dash.
Context: ...able) - --readiness-timeout <duration> - How long to wait for registry visibility...

(DASH_RULE)


[typographical] ~36-~36: To join two clauses or introduce examples, consider using an em dash.
Context: ...ult: 5m) - --readiness-poll <duration> - Poll interval for readiness checks (defa...

(DASH_RULE)


[typographical] ~37-~37: To join two clauses or introduce examples, consider using an em dash.
Context: ... checks (default: 2s) - --no-readiness - Disable readiness checks (for advanced u...

(DASH_RULE)


[typographical] ~38-~38: To join two clauses or introduce examples, consider using an em dash.
Context: ...anced users) - --output-lines <number> - Number of output lines to capture for ev...

(DASH_RULE)


[typographical] ~39-~39: To join two clauses or introduce examples, consider using an em dash.
Context: ...ence (default: 50) - --format <format> - Output format: text (default) or json - ...

(DASH_RULE)


[typographical] ~40-~40: To join two clauses or introduce examples, consider using an em dash.
Context: ...rmat: text (default) or json - --force - Force override of existing locks (use wi...

(DASH_RULE)


[typographical] ~41-~41: To join two clauses or introduce examples, consider using an em dash.
Context: ...h caution) - --lock-timeout <duration> - Lock timeout duration (default: 1h) ###...

(DASH_RULE)


[typographical] ~45-~45: To join two clauses or introduce examples, consider using an em dash.
Context: ... #### New State Files - events.jsonl - Line-delimited JSON event log for debugg...

(DASH_RULE)


[typographical] ~93-~93: To join two clauses or introduce examples, consider using an em dash.
Context: ... best fit for your workflow. ## [0.1.0] - 2025-01-15 ### Added - Initial release...

(DASH_RULE)

🤖 Prompt for AI Agents
In `@CHANGELOG.md` around lines 1 - 105, Add repository comparison links for each
released version at the bottom of CHANGELOG.md so version headers link to diffs;
add lines like "[0.2.0]:
https://github.com/<ORG>/<REPO>/compare/v0.1.0...v0.2.0" and "[0.1.0]:
https://github.com/<ORG>/<REPO>/releases/tag/v0.1.0" replacing <ORG>/<REPO> and
prior-tag names as appropriate, ensuring the identifiers exactly match the
version headers "[0.2.0]" and "[0.1.0]" so Keep a Changelog tools will resolve
them.

Comment thread LICENSE-MIT
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Complete the copyright notice with the copyright holder name.

The copyright notice is missing the name of the copyright holder. Standard MIT license practice requires specifying who holds the copyright (e.g., "Copyright (c) 2026 EffortlessMetrics" or the author's name).

📝 Suggested fix
-Copyright (c) 2026
+Copyright (c) 2026 [Your Name or Organization]

Replace the placeholder with the actual copyright holder.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Copyright (c) 2026
Copyright (c) 2026 [Your Name or Organization]
🤖 Prompt for AI Agents
In `@LICENSE-MIT` at line 3, Update the MIT license header to include the
copyright holder name by replacing the current "Copyright (c) 2026" line with a
complete notice that includes the holder (for example: "Copyright (c) 2026
EffortlessMetrics" or the project's author/organization name); ensure the holder
name is the official entity owning the code and matches other project metadata.

Comment on lines +5 to +16
- [x] Run `cargo test --workspace` — all passing (229 lib + 13 CLI unit + 20 E2E)
- [x] Run `cargo clippy --workspace -- -D warnings` — clean
- [x] Run `cargo fmt --check` — clean
- [x] Update version to 0.2.0 in workspace Cargo.toml
- [x] Update CHANGELOG.md with v0.2.0 entry (includes parallel publishing)
- [x] Verify CI templates are correct

## Code Quality Checks

- [x] All clippy warnings resolved
- [x] Code formatting passes (`cargo fmt --check`)
- [x] All tests pass (262 total: 229 lib, 13 CLI unit, 20 CLI E2E)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Test counts in the checklist are inconsistent with the PR summary.

Line 5 reports "229 lib" tests while the PR summary states 247 library tests and 280 total. If tests were added after the checklist was written, update these numbers so the checklist remains a trustworthy release artifact.

🤖 Prompt for AI Agents
In `@RELEASE_CHECKLIST_v0.2.0.md` around lines 5 - 16, Update the inconsistent
test counts in the release checklist by changing the numbers in the top
checklist and the "Code Quality Checks" block to match the PR summary (replace
"229 lib + 13 CLI unit + 20 E2E" and "262 total: 229 lib, 13 CLI unit, 20 CLI
E2E" with the PR-reported counts, e.g., "247 lib" and the correct total "280
total" and adjust the breakdown accordingly); edit the lines that contain the
literal backticked commands/labels "Run `cargo test --workspace` — all passing
(229 lib + 13 CLI unit + 20 E2E)" and the "All tests pass (262 total: 229 lib,
13 CLI unit, 20 CLI E2E)" text to the updated numbers so the checklist and Code
Quality Checks section are consistent.

Comment on lines +63 to +67
- [ ] Publish to crates.io:
```bash
cargo publish -p shipper
cargo publish -p shipper-cli
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add blank lines around the fenced code block.

The fenced code block inside the list item is missing surrounding blank lines, which can cause rendering issues in some Markdown parsers.

Proposed fix
 - [ ] Publish to crates.io:
+
   ```bash
   cargo publish -p shipper
   cargo publish -p shipper-cli
   ```
+
 - [ ] Verify packages appear on crates.io
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- [ ] Publish to crates.io:
```bash
cargo publish -p shipper
cargo publish -p shipper-cli
```
- [ ] Publish to crates.io:
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

[warning] 64-64: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


[warning] 67-67: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

🤖 Prompt for AI Agents
In `@RELEASE_CHECKLIST_v0.2.0.md` around lines 63 - 67, The checklist item "- [ ]
Publish to crates.io:" contains a fenced code block (```bash ... cargo publish
-p shipper-cli ``` ) that lacks blank lines before and after the block; update
the Markdown to add a blank line between the list item text and the opening
```bash, and another blank line after the closing ``` so the fenced code block
is separated from surrounding list items (ensure the opening ```bash and closing
``` remain intact and the two cargo publish lines are unchanged).


# Restore Shipper State (cache for faster restores)
- name: Restore Shipper State Cache
uses: actions/cache@v3

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Update actions/cache and actions/upload-artifact to v4.

actions/cache@v3 (Line 30) and actions/upload-artifact@v3 (Line 53) are outdated while actions/checkout@v4 and actions/download-artifact@v4 already use v4. Use consistent, current versions.

Proposed fix
-      - uses: actions/cache@v3
+      - uses: actions/cache@v4
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4

Also applies to: 53-53

🤖 Prompt for AI Agents
In `@templates/github-trusted-publishing.yml` at line 30, Update the workflow
steps that reference actions/cache and actions/upload-artifact to use the v4
releases to match the other v4 actions; specifically replace uses:
actions/cache@v3 with actions/cache@v4 and uses: actions/upload-artifact@v3 with
actions/upload-artifact@v4, then commit and validate the workflow syntax by
running the GitHub Actions linter or a test run to ensure compatibility with
actions/checkout@v4 and actions/download-artifact@v4.

- publish

publish:
image: rust:1.75

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for rust-version in Cargo.toml files across the repository
echo "=== Searching for rust-version in Cargo.toml files ==="
fd -t f 'Cargo.toml' | head -20 | xargs -I {} sh -c 'echo "File: {}"; grep -n "rust-version" {} || echo "Not found"'

Repository: EffortlessMetrics/shipper

Length of output: 329


🏁 Script executed:

#!/bin/bash
# Verify the template file content
echo "=== Checking templates/gitlab-publish.yml ==="
cat -n templates/gitlab-publish.yml | head -15

Repository: EffortlessMetrics/shipper

Length of output: 455


🏁 Script executed:

#!/bin/bash
# Search for release checklist or MSRV references
echo "=== Searching for release checklist or MSRV documentation ==="
fd -t f -e md -e txt | xargs grep -l "MSRV\|rust-version\|release.*checklist" 2>/dev/null | head -10

Repository: EffortlessMetrics/shipper

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Check release checklist content
echo "=== Release Checklist Content ==="
cat -n RELEASE_CHECKLIST_v0.2.0.md | head -50

Repository: EffortlessMetrics/shipper

Length of output: 2381


Rust image version 1.75 is below the project's MSRV of 1.92.

The workspace declares rust-version = "1.92", but this template uses rust:1.75. Users adopting this template will encounter compilation failures. Update to rust:1.92.

Proposed fix
-  image: rust:1.75
+  image: rust:1.92
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
image: rust:1.75
image: rust:1.92
🤖 Prompt for AI Agents
In `@templates/gitlab-publish.yml` at line 5, Update the Docker image tag used in
the CI template: replace the hardcoded image reference "image: rust:1.75" with
the project's MSRV "rust:1.92" so the job uses Rust 1.92 (locate the `image:`
line in the gitlab CI template where `rust:1.75` is declared and change it to
`rust:1.92`).

@coderabbitai coderabbitai 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.

Review continued from previous batch...

Comment on lines +437 to +597
while attempt < opts.max_attempts {
attempt += 1;
{
let pr = st
.packages
.get_mut(&key)
.context("missing package progress in state during attempt")?;
pr.attempts = attempt;
pr.last_updated_at = Utc::now();
state::save_state(&state_dir, &st)?;
}

let command = format!(
"cargo publish -p {} --registry {}",
p.name, ws.plan.registry.name
);

reporter.info(&format!(
"{}@{}: attempt {}/{}",
p.name, p.version, attempt, opts.max_attempts
));

// Event: PackageAttempted
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageAttempted {
attempt,
command: command.clone(),
},
package: pkg_label.clone(),
});

let out = cargo::cargo_publish(
workspace_root,
&p.name,
&ws.plan.registry.name,
opts.allow_dirty,
opts.no_verify,
opts.output_lines,
)?;

// Collect attempt evidence
attempt_evidence.push(AttemptEvidence {
attempt_number: attempt,
command: command.clone(),
exit_code: out.exit_code,
stdout_tail: out.stdout_tail.clone(),
stderr_tail: out.stderr_tail.clone(),
timestamp: Utc::now(),
duration: out.duration,
});

// Event: PackageOutput
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageOutput {
stdout_tail: out.stdout_tail.clone(),
stderr_tail: out.stderr_tail.clone(),
},
package: pkg_label.clone(),
});

let success = out.exit_code == 0;

if success {
reporter.info(&format!(
"{}@{}: cargo publish exited successfully; verifying...",
p.name, p.version
));
let readiness_config = crate::types::ReadinessConfig {
enabled: effects.readiness_enabled,
..opts.readiness.clone()
};
let (visible, checks) =
verify_published(&reg, &p.name, &p.version, &readiness_config, reporter)?;
readiness_evidence = checks;
if visible {
update_state(&mut st, &state_dir, &key, PackageState::Published)?;
last_err = None;

// Event: PackagePublished
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackagePublished {
duration_ms: start_instant.elapsed().as_millis() as u64,
},
package: pkg_label.clone(),
});
event_log.write_to_file(&events_path)?;
event_log.clear();

break;
} else {
// Cargo itself may warn if the index isn't updated yet. Shipper extends the wait,
// but if it still doesn't show up we treat this as ambiguous.
last_err = Some((ErrorClass::Ambiguous, "publish succeeded locally, but version not observed on registry within timeout".into()));
}
} else {
// Even if cargo fails, the publish may have succeeded (timeouts, network splits).
// Always check the registry before deciding.
reporter.warn(&format!(
"{}@{}: cargo publish failed (exit={:?}); checking registry...",
p.name, p.version, out.exit_code
));

if reg.version_exists(&p.name, &p.version)? {
reporter.info(&format!(
"{}@{}: version is present on registry; treating as published",
p.name, p.version
));
update_state(&mut st, &state_dir, &key, PackageState::Published)?;
last_err = None;
break;
}

let (class, msg) = classify_cargo_failure(&out.stderr_tail, &out.stdout_tail);
last_err = Some((class.clone(), msg.clone()));

match class {
ErrorClass::Permanent => {
let failed = PackageState::Failed {
class: class.clone(),
message: msg.clone(),
};
update_state(&mut st, &state_dir, &key, failed)?;

// Event: PackageFailed
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageFailed {
class,
message: msg,
},
package: pkg_label.clone(),
});
event_log.write_to_file(&events_path)?;
event_log.clear();

return Err(anyhow::anyhow!(
"{}@{}: permanent failure: {}",
p.name,
p.version,
last_err.unwrap().1
));
}
ErrorClass::Retryable | ErrorClass::Ambiguous => {
let delay = backoff_delay(opts.base_delay, opts.max_delay, attempt);
reporter.warn(&format!(
"{}@{}: retrying in {}",
p.name,
p.version,
humantime::format_duration(delay)
));
thread::sleep(delay);
}
}
}

// If we got here without breaking, and there are attempts left, loop.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's understand the full loop structure and control flow
cd crates/shipper/src
cat -n engine.rs | sed -n '437,597p'

Repository: EffortlessMetrics/shipper

Length of output: 7730


🏁 Script executed:

#!/bin/bash
# Let's also check what happens after the loop - is there any additional logic?
cat -n crates/shipper/src/engine.rs | sed -n '597,650p'

Repository: EffortlessMetrics/shipper

Length of output: 2567


🌐 Web query:

cargo publish behavior when publishing same version twice error handling idempotency

💡 Result:

  • crates.io does not allow republishing the same name@version. A publish is permanent and that version “can never be overwritten” (and generally isn’t deletable), to keep crates.io a stable archive. (rust-lang.github.io)
  • Therefore, running cargo publish twice for the same version is not idempotent: the second attempt will be rejected with an error like “version … already exists”, and cargo publish will exit non‑zero. (pulsartech.news)
  • Practical retry behavior: if your first publish actually succeeded but your CI/network failed before you saw the success, a retry will commonly fail with “already exists”; many release pipelines treat that specific error as “already published” and safe to stop. (crates.io has been moving publish/yank/unyank endpoints to proper 4xx/5xx codes, which makes this easier to detect programmatically.) (blog.rust-lang.org)

Citations:


Don't re‑publish after a successful cargo publish.

If cargo publish returns 0 but readiness verification times out, the loop continues without breaking, causing a re‑publish attempt. Republishing the same version violates crates.io's immutability policy and fails with a "version already exists" error, converting a successful publish into a failure. After a successful cargo publish exit code (even with readiness timeout), break out of the loop instead of retrying.

🛠️ Suggested control‑flow fix
             if success {
                 reporter.info(&format!(
                     "{}@{}: cargo publish exited successfully; verifying...",
                     p.name, p.version
                 ));
                 let readiness_config = crate::types::ReadinessConfig {
                     enabled: effects.readiness_enabled,
                     ..opts.readiness.clone()
                 };
                 let (visible, checks) =
                     verify_published(&reg, &p.name, &p.version, &readiness_config, reporter)?;
                 readiness_evidence = checks;
                 if visible {
                     update_state(&mut st, &state_dir, &key, PackageState::Published)?;
                     last_err = None;
 
                     // Event: PackagePublished
                     event_log.record(PublishEvent {
                         timestamp: Utc::now(),
                         event_type: EventType::PackagePublished {
                             duration_ms: start_instant.elapsed().as_millis() as u64,
                         },
                         package: pkg_label.clone(),
                     });
                     event_log.write_to_file(&events_path)?;
                     event_log.clear();
 
                     break;
                 } else {
                     // Cargo itself may warn if the index isn't updated yet. Shipper extends the wait,
                     // but if it still doesn't show up we treat this as ambiguous.
                     last_err = Some((ErrorClass::Ambiguous, "publish succeeded locally, but version not observed on registry within timeout".into()));
+                    // Publish already succeeded; don't retry cargo publish.
+                    break;
                 }
             } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while attempt < opts.max_attempts {
attempt += 1;
{
let pr = st
.packages
.get_mut(&key)
.context("missing package progress in state during attempt")?;
pr.attempts = attempt;
pr.last_updated_at = Utc::now();
state::save_state(&state_dir, &st)?;
}
let command = format!(
"cargo publish -p {} --registry {}",
p.name, ws.plan.registry.name
);
reporter.info(&format!(
"{}@{}: attempt {}/{}",
p.name, p.version, attempt, opts.max_attempts
));
// Event: PackageAttempted
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageAttempted {
attempt,
command: command.clone(),
},
package: pkg_label.clone(),
});
let out = cargo::cargo_publish(
workspace_root,
&p.name,
&ws.plan.registry.name,
opts.allow_dirty,
opts.no_verify,
opts.output_lines,
)?;
// Collect attempt evidence
attempt_evidence.push(AttemptEvidence {
attempt_number: attempt,
command: command.clone(),
exit_code: out.exit_code,
stdout_tail: out.stdout_tail.clone(),
stderr_tail: out.stderr_tail.clone(),
timestamp: Utc::now(),
duration: out.duration,
});
// Event: PackageOutput
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageOutput {
stdout_tail: out.stdout_tail.clone(),
stderr_tail: out.stderr_tail.clone(),
},
package: pkg_label.clone(),
});
let success = out.exit_code == 0;
if success {
reporter.info(&format!(
"{}@{}: cargo publish exited successfully; verifying...",
p.name, p.version
));
let readiness_config = crate::types::ReadinessConfig {
enabled: effects.readiness_enabled,
..opts.readiness.clone()
};
let (visible, checks) =
verify_published(&reg, &p.name, &p.version, &readiness_config, reporter)?;
readiness_evidence = checks;
if visible {
update_state(&mut st, &state_dir, &key, PackageState::Published)?;
last_err = None;
// Event: PackagePublished
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackagePublished {
duration_ms: start_instant.elapsed().as_millis() as u64,
},
package: pkg_label.clone(),
});
event_log.write_to_file(&events_path)?;
event_log.clear();
break;
} else {
// Cargo itself may warn if the index isn't updated yet. Shipper extends the wait,
// but if it still doesn't show up we treat this as ambiguous.
last_err = Some((ErrorClass::Ambiguous, "publish succeeded locally, but version not observed on registry within timeout".into()));
}
} else {
// Even if cargo fails, the publish may have succeeded (timeouts, network splits).
// Always check the registry before deciding.
reporter.warn(&format!(
"{}@{}: cargo publish failed (exit={:?}); checking registry...",
p.name, p.version, out.exit_code
));
if reg.version_exists(&p.name, &p.version)? {
reporter.info(&format!(
"{}@{}: version is present on registry; treating as published",
p.name, p.version
));
update_state(&mut st, &state_dir, &key, PackageState::Published)?;
last_err = None;
break;
}
let (class, msg) = classify_cargo_failure(&out.stderr_tail, &out.stdout_tail);
last_err = Some((class.clone(), msg.clone()));
match class {
ErrorClass::Permanent => {
let failed = PackageState::Failed {
class: class.clone(),
message: msg.clone(),
};
update_state(&mut st, &state_dir, &key, failed)?;
// Event: PackageFailed
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageFailed {
class,
message: msg,
},
package: pkg_label.clone(),
});
event_log.write_to_file(&events_path)?;
event_log.clear();
return Err(anyhow::anyhow!(
"{}@{}: permanent failure: {}",
p.name,
p.version,
last_err.unwrap().1
));
}
ErrorClass::Retryable | ErrorClass::Ambiguous => {
let delay = backoff_delay(opts.base_delay, opts.max_delay, attempt);
reporter.warn(&format!(
"{}@{}: retrying in {}",
p.name,
p.version,
humantime::format_duration(delay)
));
thread::sleep(delay);
}
}
}
// If we got here without breaking, and there are attempts left, loop.
}
while attempt < opts.max_attempts {
attempt += 1;
{
let pr = st
.packages
.get_mut(&key)
.context("missing package progress in state during attempt")?;
pr.attempts = attempt;
pr.last_updated_at = Utc::now();
state::save_state(&state_dir, &st)?;
}
let command = format!(
"cargo publish -p {} --registry {}",
p.name, ws.plan.registry.name
);
reporter.info(&format!(
"{}@{}: attempt {}/{}",
p.name, p.version, attempt, opts.max_attempts
));
// Event: PackageAttempted
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageAttempted {
attempt,
command: command.clone(),
},
package: pkg_label.clone(),
});
let out = cargo::cargo_publish(
workspace_root,
&p.name,
&ws.plan.registry.name,
opts.allow_dirty,
opts.no_verify,
opts.output_lines,
)?;
// Collect attempt evidence
attempt_evidence.push(AttemptEvidence {
attempt_number: attempt,
command: command.clone(),
exit_code: out.exit_code,
stdout_tail: out.stdout_tail.clone(),
stderr_tail: out.stderr_tail.clone(),
timestamp: Utc::now(),
duration: out.duration,
});
// Event: PackageOutput
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageOutput {
stdout_tail: out.stdout_tail.clone(),
stderr_tail: out.stderr_tail.clone(),
},
package: pkg_label.clone(),
});
let success = out.exit_code == 0;
if success {
reporter.info(&format!(
"{}@{}: cargo publish exited successfully; verifying...",
p.name, p.version
));
let readiness_config = crate::types::ReadinessConfig {
enabled: effects.readiness_enabled,
..opts.readiness.clone()
};
let (visible, checks) =
verify_published(&reg, &p.name, &p.version, &readiness_config, reporter)?;
readiness_evidence = checks;
if visible {
update_state(&mut st, &state_dir, &key, PackageState::Published)?;
last_err = None;
// Event: PackagePublished
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackagePublished {
duration_ms: start_instant.elapsed().as_millis() as u64,
},
package: pkg_label.clone(),
});
event_log.write_to_file(&events_path)?;
event_log.clear();
break;
} else {
// Cargo itself may warn if the index isn't updated yet. Shipper extends the wait,
// but if it still doesn't show up we treat this as ambiguous.
last_err = Some((ErrorClass::Ambiguous, "publish succeeded locally, but version not observed on registry within timeout".into()));
// Publish already succeeded; don't retry cargo publish.
break;
}
} else {
// Even if cargo fails, the publish may have succeeded (timeouts, network splits).
// Always check the registry before deciding.
reporter.warn(&format!(
"{}@{}: cargo publish failed (exit={:?}); checking registry...",
p.name, p.version, out.exit_code
));
if reg.version_exists(&p.name, &p.version)? {
reporter.info(&format!(
"{}@{}: version is present on registry; treating as published",
p.name, p.version
));
update_state(&mut st, &state_dir, &key, PackageState::Published)?;
last_err = None;
break;
}
let (class, msg) = classify_cargo_failure(&out.stderr_tail, &out.stdout_tail);
last_err = Some((class.clone(), msg.clone()));
match class {
ErrorClass::Permanent => {
let failed = PackageState::Failed {
class: class.clone(),
message: msg.clone(),
};
update_state(&mut st, &state_dir, &key, failed)?;
// Event: PackageFailed
event_log.record(PublishEvent {
timestamp: Utc::now(),
event_type: EventType::PackageFailed {
class,
message: msg,
},
package: pkg_label.clone(),
});
event_log.write_to_file(&events_path)?;
event_log.clear();
return Err(anyhow::anyhow!(
"{}@{}: permanent failure: {}",
p.name,
p.version,
last_err.unwrap().1
));
}
ErrorClass::Retryable | ErrorClass::Ambiguous => {
let delay = backoff_delay(opts.base_delay, opts.max_delay, attempt);
reporter.warn(&format!(
"{}@{}: retrying in {}",
p.name,
p.version,
humantime::format_duration(delay)
));
thread::sleep(delay);
}
}
}
// If we got here without breaking, and there are attempts left, loop.
}
🤖 Prompt for AI Agents
In `@crates/shipper/src/engine.rs` around lines 437 - 597, The loop currently
retries when cargo publish exits successfully but verify_published returns not
visible, causing a re-publish; instead, after a successful cargo publish (the
success branch where out.exit_code == 0), if verify_published reports not
visible, mark the package as Published and exit the loop rather than retrying.
Concretely, inside the success branch after verify_published (the block handling
visible == false), call update_state(&mut st, &state_dir, &key,
PackageState::Published)?, clear last_err, record the PackagePublished
PublishEvent and write/clear event_log (same as the visible==true path), set
readiness_evidence appropriately, then break; ensure you reference
verify_published, update_state, PackageState::Published, event_log.record/
write_to_file/clear, and readiness_evidence when making the change.

Comment on lines +64 to +139
// Build dependency edges A->deps (restricted to publishable workspace members).
let resolve = metadata
.resolve
.as_ref()
.context("cargo metadata did not include a resolve graph")?;

let mut deps_of: BTreeMap<PackageId, BTreeSet<PackageId>> = BTreeMap::new();
let mut dependents_of: BTreeMap<PackageId, BTreeSet<PackageId>> = BTreeMap::new();

for node in &resolve.nodes {
if !publishable.contains(&node.id) {
continue;
}
for dep in &node.deps {
if !publishable.contains(&dep.pkg) {
continue;
}

let is_relevant = dep
.dep_kinds
.iter()
.any(|k| matches!(k.kind, DependencyKind::Normal | DependencyKind::Build));
if !is_relevant {
continue;
}

deps_of
.entry(node.id.clone())
.or_default()
.insert(dep.pkg.clone());
dependents_of
.entry(dep.pkg.clone())
.or_default()
.insert(node.id.clone());
}
}

// Determine which nodes to include.
let included: BTreeSet<PackageId> = if let Some(sel) = &spec.selected_packages {
// Map package name -> id (workspace publishable only).
let mut name_to_id: BTreeMap<String, PackageId> = BTreeMap::new();
for id in &publishable {
let pkg = pkg_map
.get(id)
.context("workspace package missing from metadata")?;
name_to_id.insert(pkg.name.to_string(), id.clone());
}

let mut queue: VecDeque<PackageId> = VecDeque::new();
let mut set: BTreeSet<PackageId> = BTreeSet::new();

for name in sel {
let id = name_to_id
.get(name)
.with_context(|| format!("selected package not found or not publishable: {name}"))?
.clone();
if set.insert(id.clone()) {
queue.push_back(id);
}
}

// Include internal dependencies transitively.
while let Some(id) = queue.pop_front() {
if let Some(deps) = deps_of.get(&id) {
for dep in deps {
if set.insert(dep.clone()) {
queue.push_back(dep.clone());
}
}
}
}

set
} else {
publishable.clone()
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Cargo publish behavior non-publishable workspace member dependency

💡 Result:

When you cargo publish a crate that depends on another workspace member that is not publishable (package.publish = false), the publish will fail unless that dependency is not part of the published dependency graph.

Key rules/behavior:

  • crates.io forbids published crates from having path (or git) dependencies in normal [dependencies] / [build-dependencies]. Cargo enforces this during cargo publish/--dry-run. (doc.rust-lang.org)
  • The supported workflow for workspace members is: specify both version and path:
    dep_crate = { version = "0.1.0", path = "../dep_crate" }
    Cargo uses the path locally, but strips the path from the published manifest, so the published crate depends on the registry version. (doc.rust-lang.org)
  • If the dependency crate is publish = false, then (by definition) it won’t be on crates.io—so a published crate cannot depend on it as a normal dependency. Your options are:
    1. Publish the dependency crate (even if you don’t advertise it), and use version + path. (doc.rust-lang.org)
    2. Stop making it a normal dependency of the published crate (merge code, or move it behind test-only usage).
    3. If it’s only needed for tests/benches/examples, move it to [dev-dependencies] (crates.io’s restriction doesn’t apply the same way there). (doc.rust-lang.org)

Also note: running cargo publish from a workspace root selects packages based on workspace membership/default-members/--workspace, but it still won’t let a published crate carry non-registry dependencies. (doc.rust-lang.org)

Citations:


🏁 Script executed:

# First, examine the actual code in the file to understand the filtering logic
cd crates/shipper && head -200 src/plan.rs | tail -150

Repository: EffortlessMetrics/shipper

Length of output: 5042


🏁 Script executed:

# Look at the full function and surrounding context to see if there's validation
cat -n crates/shipper/src/plan.rs | head -160 | tail -100

Repository: EffortlessMetrics/shipper

Length of output: 4077


🏁 Script executed:

# Search for any validation logic that checks workspace dependencies
rg -A5 -B5 "publishable" crates/shipper/src/plan.rs | head -80

Repository: EffortlessMetrics/shipper

Length of output: 2462


🏁 Script executed:

# Check if there's any validation logic elsewhere that detects publishable->non-publishable deps
rg "non.*publishable|publishable.*dep" crates/shipper/src/ -A3 -B3

Repository: EffortlessMetrics/shipper

Length of output: 411


🏁 Script executed:

# Look for any error handling or validation related to workspace dependencies
rg "workspace.*dep|dep.*workspace" crates/shipper/src/ -A2 -B2

Repository: EffortlessMetrics/shipper

Length of output: 982


🏁 Script executed:

# Check the metadata usage to see what information is available about dependencies
rg "\.deps\|\.dependencies" crates/shipper/src/ -A2 -B2

Repository: EffortlessMetrics/shipper

Length of output: 51


Detect and reject publishable crates with non-publishable workspace dependencies.

The dependency graph filters edges to publishable packages only (line 78), but this means dependencies on non-publishable workspace members are silently discarded. A publishable crate can still have real (Cargo.toml) dependencies on non-publishable workspace members, which Cargo will reject during cargo publish since those packages won't exist on the registry. The plan should detect such crates and either reject them or skip them with a clear error message.

🤖 Prompt for AI Agents
In `@crates/shipper/src/plan.rs` around lines 64 - 139, When building dependency
edges in the loop over resolve.nodes (the block that currently fills
deps_of/dependents_of), detect if a publishable package depends on a workspace
member that is not in publishable: for each dep in node.deps (and where
dep_kinds contains Normal/Build) if pkg_map.contains_key(&dep.pkg) &&
!publishable.contains(&dep.pkg) then return an error (or otherwise reject/skip)
with a clear message naming the publishable package (node.id or
pkg_map.get(&node.id).name) and the non-publishable workspace dependency
(dep.pkg or its name). Add this check before silently continuing so such invalid
publishable crates are reported rather than dropping the edge.

Comment on lines +133 to +188
/// Calculate the index path for a crate using the 2+2+N scheme.
///
/// For example, "serde" becomes "s/e/serde"
/// and "tokio" becomes "t/o/tokio"
fn calculate_index_path(&self, crate_name: &str) -> String {
let chars: Vec<char> = crate_name.chars().collect();
let first = if !chars.is_empty() { chars[0] } else { '_' };
let second = if chars.len() > 1 { chars[1] } else { '_' };

// Handle special cases: crates starting with non-alphanumeric characters
let first = if first.is_alphanumeric() { first } else { '_' };
let second = if second.is_alphanumeric() {
second
} else {
'_'
};

format!("{}/{}/{}", first, second, crate_name)
}

/// Fetch the index file content from the registry.
fn fetch_index_file(&self, index_path: &str) -> Result<String> {
let index_base = self.registry.get_index_base();
let url = format!("{}/{}", index_base.trim_end_matches('/'), index_path);

let resp = self.http.get(&url).send().context("index request failed")?;

match resp.status() {
StatusCode::OK => {
let content = resp.text().context("failed to read index response body")?;
Ok(content)
}
StatusCode::NOT_FOUND => {
// The crate doesn't exist in the index yet
bail!("index file not found: {}", url)
}
s => bail!("unexpected status while fetching index: {}", s),
}
}

/// Parse the index JSON and check if the version exists.
fn parse_version_from_index(&self, content: &str, version: &str) -> Result<bool> {
// The sparse index format is a JSON array of version objects
#[derive(Deserialize)]
struct IndexVersion {
#[allow(dead_code)]
#[serde(rename = "name")]
_name: String,
vers: String,
}

let versions: Vec<IndexVersion> = serde_json::from_str(content)
.with_context(|| format!("failed to parse index JSON for version {}", version))?;

Ok(versions.iter().any(|v| v.vers == 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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Cargo sparse registry index path format specification 1/ 2/ 3/ prefix

💡 Result:

Cargo’s registry index (both git and sparse) stores each crate’s metadata file at a path derived from the crate name (with the filename being the crate name in lowercase). The directory tiers are: (doc.rust-lang.org)

  • Length = 1: 1/<name>
  • Length = 2: 2/<name>
  • Length = 3: 3/<first-char>/<name>
  • Length ≥ 4: <first-two>/<second-two>/<name>

In config.json download templates, Cargo exposes this directory portion as:

  • {prefix} = computed from the original crate name’s case (e.g., MyCrateMy/Cr)
  • {lowerprefix} = computed from the lowercase name (e.g., my/cr) (doc.rust-lang.org)

Citations:


🌐 Web query:

Cargo sparse index file format JSON lines vs array crates.io

💡 Result:

For crates.io’s registry index (whether accessed via git or the sparse protocol), each crate’s index file is newline-delimited JSON (JSON Lines / NDJSON): one JSON object per line, where each line describes one published version of that crate. It is not a single JSON array. [1]

Separately, the index root contains a config.json file, which is a normal single JSON object (not JSON Lines). [1]

The sparse protocol doesn’t change the file format—Cargo just fetches those same per-crate files over HTTP instead of via a git clone. [1]

Sources:
[1] Cargo Book — Registry Index (Index files format + sparse vs git protocols) (doc.rust-lang.org)

Citations:


Sparse index path and format are incorrect per Cargo's specification.

Cargo's sparse index stores crates at paths with special handling for short names: 1/<name> (length 1), 2/<name> (length 2), 3/<first>/<name> (length 3), or <first-two>/<second-two>/<name> (length ≥4). Index files are newline-delimited JSON (JSONL), not JSON arrays. The current implementation applies 2+2+N uniformly and attempts to parse a JSON array, which will fail on all real Cargo index files.

🛠️ Suggested fix for sparse index path + JSONL parsing
-    fn calculate_index_path(&self, crate_name: &str) -> String {
-        let chars: Vec<char> = crate_name.chars().collect();
-        let first = if !chars.is_empty() { chars[0] } else { '_' };
-        let second = if chars.len() > 1 { chars[1] } else { '_' };
-
-        // Handle special cases: crates starting with non-alphanumeric characters
-        let first = if first.is_alphanumeric() { first } else { '_' };
-        let second = if second.is_alphanumeric() {
-            second
-        } else {
-            '_'
-        };
-
-        format!("{}/{}/{}", first, second, crate_name)
-    }
+    fn calculate_index_path(&self, crate_name: &str) -> String {
+        // Cargo sparse index layout:
+        // 1/<name>, 2/<name>, 3/<first>/<name>, or <first2>/<second2>/<name>
+        match crate_name.len() {
+            1 => format!("1/{}", crate_name),
+            2 => format!("2/{}", crate_name),
+            3 => format!("3/{}/{}", &crate_name[0..1], crate_name),
+            _ => format!(
+                "{}/{}/{}",
+                &crate_name[0..2],
+                &crate_name[2..4],
+                crate_name
+            ),
+        }
+    }
@@
-        let versions: Vec<IndexVersion> = serde_json::from_str(content)
-            .with_context(|| format!("failed to parse index JSON for version {}", version))?;
-
-        Ok(versions.iter().any(|v| v.vers == version))
+        for line in content.lines() {
+            let line = line.trim();
+            if line.is_empty() {
+                continue;
+            }
+            let v: IndexVersion = serde_json::from_str(line).with_context(|| {
+                format!("failed to parse index JSON line for version {}", version)
+            })?;
+            if v.vers == version {
+                return Ok(true);
+            }
+        }
+
+        Ok(false)
     }
🤖 Prompt for AI Agents
In `@crates/shipper/src/registry.rs` around lines 133 - 188, calculate_index_path
currently always uses a 2+2+N layout and parse_version_from_index treats the
index as a JSON array; fix both to follow Cargo's sparse index spec: update
calculate_index_path to return "1/<name>" for len==1, "2/<name>" for len==2,
"3/<first>/<name>" for len==3, and "<first_two>/<second_two>/<name>" for len>=4
(use the first two and next two characters of crate_name), and update
parse_version_from_index to parse newline-delimited JSON (JSONL) by iterating
each non-empty line, deserializing into the IndexVersion struct and checking
vers == version (instead of parsing the entire content as an array); keep the
existing IndexVersion struct and the same error context when deserialization of
a line fails.

Comment on lines +199 to +219
fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
let tmp = path.with_extension("tmp");
let data = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;

{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("failed to create tmp file {}", tmp.display()))?;
f.write_all(&data)
.with_context(|| format!("failed to write tmp file {}", tmp.display()))?;
f.sync_all().ok();
}

fs::rename(&tmp, path).with_context(|| {
format!(
"failed to rename tmp file {} to {}",
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.

⚠️ Potential issue | 🟠 Major

Sync the parent directory after atomic rename.

atomic_write_json fsyncs the temp file but not the directory. On crash/power loss, the rename can be lost, which undermines “crash‑safe” persistence. Sync the parent directory after the rename.

🛠️ Suggested durability fix
     fs::rename(&tmp, path).with_context(|| {
         format!(
             "failed to rename tmp file {} to {}",
             tmp.display(),
             path.display()
         )
     })?;
+
+    if let Some(parent) = path.parent() {
+        let dir = fs::File::open(parent)
+            .with_context(|| format!("failed to open dir {}", parent.display()))?;
+        dir.sync_all()
+            .with_context(|| format!("failed to sync dir {}", parent.display()))?;
+    }
 
     Ok(())
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
let tmp = path.with_extension("tmp");
let data = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("failed to create tmp file {}", tmp.display()))?;
f.write_all(&data)
.with_context(|| format!("failed to write tmp file {}", tmp.display()))?;
f.sync_all().ok();
}
fs::rename(&tmp, path).with_context(|| {
format!(
"failed to rename tmp file {} to {}",
tmp.display(),
path.display()
)
})?;
Ok(())
fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
let tmp = path.with_extension("tmp");
let data = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("failed to create tmp file {}", tmp.display()))?;
f.write_all(&data)
.with_context(|| format!("failed to write tmp file {}", tmp.display()))?;
f.sync_all().ok();
}
fs::rename(&tmp, path).with_context(|| {
format!(
"failed to rename tmp file {} to {}",
tmp.display(),
path.display()
)
})?;
if let Some(parent) = path.parent() {
let dir = fs::File::open(parent)
.with_context(|| format!("failed to open dir {}", parent.display()))?;
dir.sync_all()
.with_context(|| format!("failed to sync dir {}", parent.display()))?;
}
Ok(())
}
🤖 Prompt for AI Agents
In `@crates/shipper/src/state.rs` around lines 199 - 219, The function
atomic_write_json performs fsync on the temp file but does not fsync the parent
directory after fs::rename, so the rename may be lost on crash; modify
atomic_write_json to, after fs::rename(&tmp, path) succeeds, open the parent
directory (using path.parent()) as a File (via
OpenOptions::new().read(true).open(parent_dir) or similar) and call sync_all()
on that directory File, propagating errors with context (e.g., "failed to sync
parent directory ..."); handle the case where path.parent() is None
appropriately and include with_context messages consistent with existing error
handling.

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.

2 participants