Skip to content

feat: Uploaded resume state + evidence redaction#2

Merged
EffortlessSteven merged 5 commits into
mainfrom
next-pr
Feb 16, 2026
Merged

feat: Uploaded resume state + evidence redaction#2
EffortlessSteven merged 5 commits into
mainfrom
next-pr

Conversation

@EffortlessSteven

Copy link
Copy Markdown
Member

Summary

  • Uploaded intermediate state: PackageState::Uploaded between Pending and Published,
    persisted after cargo publish exits 0 but before readiness verification. On resume, skips
    cargo publish and goes straight to registry verification. Prevents duplicate uploads.
  • Evidence redaction: redact_sensitive() strips tokens from stdout/stderr tails before
    writing to receipts and event logs. Prevents credential leakage in .shipper/ artifacts.
  • Refactoring (earlier commits): temp-env test migration, improved error handling, file sync.

Test plan

  • uploaded_state_serde_roundtrip — JSON round-trip for new variant
  • resume_from_uploaded_skips_cargo_publish_and_reaches_published — e2e resume test
  • 8 redaction unit tests (bearer, tokens, env vars, passthrough, empty, multi-pattern)
  • Full suite: 293 tests passing, zero clippy warnings

…ness checks

- Enhanced the `publish_package` function in `engine_parallel.rs` to ensure that the publishing process respects the `PublishPolicy::Fast` and correctly handles cargo publish failures.
- Introduced a new mechanism to log events for package attempts and outputs, improving traceability during the publishing process.
- Implemented readiness verification after a successful cargo publish, ensuring that the package is correctly registered before concluding the publish operation.
- Updated the `build_plan` function in `plan.rs` to reject publishable crates that depend on non-publishable workspace members, enhancing dependency validation.
- Modified the index path calculation in `registry.rs` to align with Cargo's sparse index scheme, improving the efficiency of crate version lookups.
- Added tests to validate the new dependency checks and index path calculations, ensuring robustness in the package management workflow.
- Updated fuzz tests to utilize the `temp-env` crate for managing environment variables during fuzzing, enhancing test isolation.
- Removed redundant backoff delay calculation in `publish_package` function.
- Introduced `fsync_parent_dir` function to ensure parent directory durability after renaming lock files in `LockFile` implementation.
- Updated `build_plan` function to validate included crates against non-publishable workspace members, enhancing dependency checks.
- Modified `calculate_index_path` to use `to_ascii_lowercase` for crate name normalization.
- Ensured atomic write operations sync the parent directory to prevent data loss on crash.
…ish on resume

Introduce PackageState::Uploaded between Pending and Published. When cargo
publish exits 0, state is persisted as Uploaded before readiness verification.
On resume, Uploaded packages skip cargo publish and proceed directly to
registry verification, closing a window where crash-resume could re-upload.
Add redact_sensitive() applied inside tail_lines() so all stdout/stderr tails
are scrubbed before reaching receipts or event logs. Covers Authorization
Bearer headers, token assignments, and CARGO_REGISTRY_TOKEN / CARGO_REGISTRIES
env vars.
Copilot AI review requested due to automatic review settings February 16, 2026 03:36
@coderabbitai

coderabbitai Bot commented Feb 16, 2026

Copy link
Copy Markdown

Summary by CodeRabbit

Release Notes

  • New Features

    • Added "Uploaded" package state for enhanced tracking during publish operations.
    • Introduced timeout support for cargo publish operations.
    • Automatic redaction of sensitive credentials (tokens, authorization headers) in command output.
    • Workspace dependency validation prevents publishable packages from depending on non-publishable members.
    • Sparse index registry support for improved package lookups.
  • Bug Fixes

    • Enhanced durability of state file persistence.

Walkthrough

This pull request introduces substantial enhancements to the package publishing pipeline: a new PackageState::Uploaded variant for tracking intermediate publish states, comprehensive redaction of sensitive data in cargo output, timeout support for publish operations, a Cargo-compatible sparse index scheme for registry lookups, validation preventing publishable packages from depending on non-publishable ones, and refactored test infrastructure replacing custom environment guards with the temp-env crate. Filesystem sync operations are added to ensure durability after atomic state writes.

Changes

Cohort / File(s) Summary
Dependency and Manifest Updates
crates/shipper-cli/Cargo.toml, crates/shipper/Cargo.toml, fuzz/Cargo.toml
Added temp-env = "0.3" dev-dependency and workspace lint configuration to multiple Cargo manifests for improved test environment isolation.
Type System Enhancements
crates/shipper/src/types.rs
Introduced PackageState::Uploaded enum variant; added sparse index prefix stripping in Registry::get_index_base; updated tests for serde roundtrip and sparse-prefix handling.
Test Infrastructure Refactoring
crates/shipper-cli/src/main.rs, crates/shipper/src/auth.rs, crates/shipper/src/git.rs, fuzz/fuzz_targets/resolve_token.rs
Replaced custom EnvGuard pattern with temp_env::with_vars/with_var scoped helpers across test suites; no functional logic changes to production code.
Output Redaction and Timeout
crates/shipper/src/cargo.rs
Implemented comprehensive redaction pipeline (redact_sensitive, redact_line) to mask Bearer tokens and CARGO_TOKEN environment variables in output; added optional timeout parameter to cargo_publish with polled execution path; expanded test coverage for redaction behaviors.
State Persistence and Durability
crates/shipper/src/state.rs, crates/shipper/src/lock.rs
Introduced fsync_parent_dir helper to sync parent directory after atomic writes; integrated fsync calls in lock file operations to ensure durable state transitions.
Registry Index Scheme
crates/shipper/src/registry.rs
Replaced 2+2+N indexing with Cargo-style sparse paths (length-based prefixing); updated index parsing from JSON array to line-delimited JSON (JSONL); adjusted test expectations for new URL path structures and index formats.
Plan Validation
crates/shipper/src/plan.rs
Added validation preventing publishable workspace members from depending on non-publishable ones; expanded test scaffolding with conditional non-publishable package inclusion and error case coverage.
Engine State Machine and Policy
crates/shipper/src/engine.rs
Expanded PolicyEffects visibility to pub(crate) with public fields; made apply_policy crate-public; integrated PackageState::Uploaded into state-check branches and progress flow; refined ownership verification for new vs. existing crates; added cargo_succeeded flag for conditional event emission; extended resume logic to handle Uploaded state; refined readiness verification and event logging; significantly expanded test scaffolding.
Parallel Publishing Flow
crates/shipper/src/engine_parallel.rs
Introduced cargo_succeeded flag and pre-check for Uploaded state resumption; added conditional cargo publish invocation with granular event logging; implemented readiness verification with backoff; integrated registry visibility checks for determining final Published state; added fallback logic and final state determination; updated test infrastructure for environment variable handling and per-package behavior verification.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI/Main
    participant Engine as Engine
    participant Cargo as Cargo
    participant State as State Mgmt
    participant Registry as Registry

    rect rgba(100, 150, 255, 0.5)
    Note over CLI,Registry: Publishing Flow (with Resume)
    CLI->>Engine: initiate publish (check if resumed from Uploaded)
    Engine->>State: load package state
    State-->>Engine: PackageState::Uploaded or Pending
    alt Is Uploaded (Resume)
        Engine->>Registry: verify version exists
        Registry-->>Engine: version confirmed
        Engine->>State: mark as Published
        Engine-->>CLI: skip cargo, done
    else Is Pending (New Attempt)
        Engine->>Cargo: cargo publish with timeout
        Cargo-->>Engine: result (success/failure)
        alt Cargo Success
            Engine->>State: mark as Uploaded
            State-->>Engine: state saved
            Engine->>Registry: verify published
            Registry-->>Engine: visible
            Engine->>State: mark as Published
            Engine-->>CLI: publish complete
        else Cargo Failure
            Engine->>Registry: check if already published
            Registry-->>Engine: found or not found
            alt Version Found
                Engine->>State: mark as Published
                Engine-->>CLI: already on registry
            else Not Found
                Engine->>State: mark as Failed
                Engine-->>CLI: publish failed
            end
        end
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰✨ A whisker-twitching update brings new states to light,
With Uploaded paths guiding packages just right,
Secrets redacted safe in the shadows they hide,
Sparse indices gleaming with Cargo's own pride,
Through validation and sync, our pipelines now glide! 🚀

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: Uploaded resume state + evidence redaction' clearly and specifically summarizes the two main features introduced in this PR: the new Uploaded state for resumable publishing and the redaction of sensitive credentials.
Description check ✅ Passed The description comprehensively explains both main features (Uploaded state and evidence redaction), provides context on refactoring efforts, and includes a clear test plan with specific test cases, directly relating to the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% 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 next-pr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello @EffortlessSteven, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the robustness and security of the publishing process. It introduces an intermediate "Uploaded" state to improve resume capabilities, preventing redundant cargo publish calls. Critical security improvements are made by redacting sensitive information from logs and receipts. Additionally, it refactors environment variable management in tests for better isolation and correctness, updates the Cargo index path calculation for accuracy, and adds a crucial validation step for workspace dependencies.

Highlights

  • Uploaded Intermediate State: Introduced a new PackageState::Uploaded to track packages that have successfully completed cargo publish but are awaiting registry verification, enabling resume functionality to skip re-publishing.
  • Evidence Redaction: Implemented redact_sensitive() to strip sensitive tokens and credentials from command output before logging, enhancing security.
  • Test Environment Refactoring: Refactored environment variable handling in tests across multiple crates to use the temp-env crate, improving test reliability and safety by avoiding unsafe std::env calls.
  • Cargo Index Path Calculation Update: Updated the calculate_index_path logic in the registry client to align with Cargo's sparse index scheme, ensuring compatibility with modern registry implementations.
  • Workspace Dependency Validation: Added validation to prevent publishable packages from having normal or build dependencies on non-publishable workspace members, enforcing stricter package graph integrity.
Changelog
  • Cargo.lock
    • Added temp-env dependency.
  • crates/shipper-cli/Cargo.toml
    • Added temp-env as a dev-dependency.
    • Introduced a [lints] section with workspace = true.
  • crates/shipper-cli/src/main.rs
    • Added display logic for the new PackageState::Uploaded.
    • Migrated test environment variable manipulation from std::env to temp_env::with_vars.
    • Removed unused restore_env helper function.
  • crates/shipper/Cargo.toml
    • Added temp-env as a dev-dependency.
    • Introduced a [lints] section with workspace = true.
  • crates/shipper/src/auth.rs
    • Replaced custom EnvGuard struct with temp_env::with_vars for managing environment variables in tests.
  • crates/shipper/src/cargo.rs
    • Implemented redact_sensitive and redact_line functions to filter sensitive data from output.
    • Integrated redact_sensitive into tail_lines to redact output before storage.
    • Added a timeout parameter to the cargo_publish function, allowing for process timeout enforcement.
    • Refactored test environment variable handling to use temp-env.
  • crates/shipper/src/engine.rs
    • Modified PolicyEffects and apply_policy to be public for crate visibility.
    • Updated run_preflight to skip strict ownership checks for new crates.
    • Introduced cargo_succeeded flag and PackageState::Uploaded handling in run_publish to enable resume functionality.
    • Updated short_state to include the new Uploaded state.
    • Migrated test environment variable manipulation to temp-env.
  • crates/shipper/src/engine_parallel.rs
    • Added logic to check for PackageState::Uploaded at the start of publish_package to skip cargo publish on resume.
    • Passed per_package_timeout to cargo_publish calls.
    • Applied policy effects for readiness checks in parallel mode.
  • crates/shipper/src/git.rs
    • Replaced custom EnvGuard with temp_env::with_vars for managing environment variables in tests.
  • crates/shipper/src/lock.rs
    • Imported fsync_parent_dir from crate::state.
    • Added calls to fsync_parent_dir after renaming lock files for improved durability.
  • crates/shipper/src/plan.rs
    • Added validation to build_plan to prevent publishable packages from depending on non-publishable workspace members via normal or build dependencies.
    • Adjusted create_workspace test helper to optionally include npdep.
  • crates/shipper/src/registry.rs
    • Revised calculate_index_path to conform to Cargo's sparse index scheme (1-char, 2-char, 3-char, 4+ char rules).
    • Modified parse_version_from_index to correctly parse line-delimited JSON (JSONL) and gracefully handle malformed entries.
    • Updated test assertions for index path calculation and version parsing.
  • crates/shipper/src/state.rs
    • Added fsync_parent_dir function to ensure directory entry updates are durable.
    • Integrated fsync_parent_dir into atomic_write_json after file renames.
  • crates/shipper/src/types.rs
    • Defined a new PackageState::Uploaded enum variant.
    • Updated Registry::get_index_base to strip the sparse+ prefix from index URLs.
    • Added a test for Uploaded state serialization/deserialization.
    • Updated proptests for calculate_index_path_for_crate to reflect the new sparse index scheme.
  • fuzz/Cargo.toml
    • Added temp-env dependency for fuzzing.
  • fuzz/fuzz_targets/resolve_token.rs
    • Replaced unsafe environment variable manipulation with temp_env::with_vars for fuzz tests.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces some excellent features and fixes. The new Uploaded state will significantly improve the robustness of resuming publishes, preventing duplicate uploads. The evidence redaction is a crucial security enhancement to prevent credential leakage in logs.

I'm also impressed with the bug fixes for sparse index path calculation and parsing in registry.rs, which correctly align the implementation with Cargo's behavior. The addition of validation to prevent publishing crates with non-publishable workspace dependencies is another great proactive check.

The extensive refactoring to use temp-env throughout the tests is a great move for test safety and readability.

I have a couple of minor suggestions in cargo.rs to further improve the robustness and clarity of the new redaction logic. Overall, this is a very strong set of changes.

Comment on lines +47 to +53
if let Some(pos) = out.to_lowercase().find("authorization:") {
let after = &out[pos..];
if let Some(bearer_pos) = after.to_lowercase().find("bearer ") {
let redact_start = pos + bearer_pos + "bearer ".len();
out = format!("{}[REDACTED]", &out[..redact_start]);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using to_lowercase().find() and then using the resulting byte offset to slice the original string can lead to a panic if the string contains multi-byte characters where the lowercase representation has a different byte length. While HTTP headers are typically ASCII, making this robust against all UTF-8 strings would be safer. A potential approach is to use a regex with a case-insensitive flag, or iterate character indices to find the correct byte boundaries.

}

// token = "<value>" or token = '<value>' or token = <value>
if let Some(pos) = out.find("token") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The search for "token" is case-sensitive. This is inconsistent with the case-insensitive handling of Authorization: on line 47. For better robustness, you could consider making this search case-insensitive as well. Note that using to_lowercase().find() can be problematic with non-ASCII characters, as mentioned in another comment.

Comment on lines +62 to +68
let val_offset = eq_offset + 1 + (trimmed.len() - 1 - after_eq.len());
// Check for quoted or unquoted value
if after_eq.starts_with('"') || after_eq.starts_with('\'') {
out = format!("{}= \"[REDACTED]\"", &out[..eq_offset]);
} else if !after_eq.is_empty() {
let _ = val_offset; // suppress unused
out = format!("{}= [REDACTED]", &out[..eq_offset]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The val_offset variable is calculated on line 62 but its value is never used, other than to suppress a compiler warning on line 67. This unused code can be removed to improve clarity and maintainability.

            // Check for quoted or unquoted value
            if after_eq.starts_with('"') || after_eq.starts_with('\'') {
                out = format!("{}= \"[REDACTED]\"", &out[..eq_offset]);
            } else if !after_eq.is_empty() {
                out = format!("{}= [REDACTED]", &out[..eq_offset]);
            }

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 introduces two main features for the shipper release automation tool: an intermediate "Uploaded" state for resumable publishes and evidence redaction to prevent credential leakage. The PR also includes a significant refactoring to use the temp-env crate for safer test environment variable management, updates to the sparse index implementation to match Cargo's actual scheme, and improvements to file syncing for durability.

Changes:

  • Adds PackageState::Uploaded as an intermediate state between Pending and Published, allowing resume operations to skip cargo publish and jump directly to readiness verification
  • Implements evidence redaction in cargo.rs to strip tokens, bearer headers, and credential environment variables from stdout/stderr before persisting to receipts and event logs
  • Migrates all test code from unsafe env::set_var/env::remove_var to the temp-env crate for safer and more reliable test isolation
  • Fixes sparse index path calculation to match Cargo's actual 1/2/3/4+ character scheme instead of the previous incorrect 2+2+N implementation
  • Adds fsync_parent_dir to ensure directory entry durability after atomic renames on crash
  • Adds validation to reject publishable packages that depend on non-publishable workspace members (normal/build dependencies only)
  • Adds per-package timeout support for parallel mode publishes
  • Skips ownership checks for new crates in strict mode (since the owners endpoint doesn't exist yet)

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
crates/shipper/src/types.rs Adds Uploaded variant to PackageState enum; fixes get_index_base to strip sparse+ prefix; adds test for new state
crates/shipper/src/engine.rs Implements resume-from-Uploaded logic (sequential mode); skips ownership check for new crates in strict mode; updates success checks to include Uploaded state
crates/shipper/src/engine_parallel.rs Implements resume-from-Uploaded logic (parallel mode); passes per-package timeout to cargo_publish; applies policy effects for readiness
crates/shipper/src/cargo.rs Adds redact_sensitive function to strip tokens from output; implements per-package timeout with polling; updates cargo_publish signature with optional timeout parameter; adds 8 redaction unit tests
crates/shipper/src/state.rs Adds fsync_parent_dir helper for directory sync after atomic writes
crates/shipper/src/lock.rs Calls fsync_parent_dir after lock file operations for crash safety
crates/shipper/src/registry.rs Fixes sparse index path calculation to match Cargo's 1/2/3/4+ scheme; updates index parsing from JSON array to line-delimited JSON; updates all related tests
crates/shipper/src/plan.rs Adds validation to reject publishable packages depending on non-publishable workspace members; adds helper to conditionally include npdep test package; adds 3 validation tests
crates/shipper/src/git.rs Migrates from custom EnvGuard to temp-env crate
crates/shipper/src/auth.rs Migrates from custom EnvGuard to temp-env crate
crates/shipper-cli/src/main.rs Adds "Uploaded" (yellow) state display in receipt inspection; migrates tests to temp-env
crates/shipper/Cargo.toml Adds temp-env = "0.3" dev-dependency and workspace lints
crates/shipper-cli/Cargo.toml Adds temp-env = "0.3" dev-dependency and workspace lints
fuzz/fuzz_targets/resolve_token.rs Migrates from unsafe env manipulation to temp-env
fuzz/Cargo.toml Adds temp-env = "0.3" dependency
Cargo.lock Adds temp-env crate lock entries
Comments suppressed due to low confidence (2)

crates/shipper/src/engine.rs:440

  • When resuming from the Uploaded state in sequential mode, the initial attempt count is loaded from the saved state at lines 444-448. However, this happens after the "already published" check at line 401. If a package is in Uploaded state but the version now exists on the registry (perhaps uploaded in a previous interrupted run), it will be marked as Skipped without updating the attempts count in the receipt. This could lead to misleading receipt data where a package shows 0 attempts but was actually uploaded.

The cargo_succeeded flag is set correctly at line 382, but if the version already exists, that flag is never used because the code continues before entering the retry loop.

            PackageState::Uploaded => {
                reporter.info(&format!(
                    "{}@{}: resuming from uploaded (skipping cargo publish)",
                    p.name, p.version
                ));
                cargo_succeeded = true;
            }
            _ => {}
        }

        // Event: PackageStarted
        event_log.record(PublishEvent {
            timestamp: Utc::now(),
            event_type: EventType::PackageStarted {
                name: p.name.clone(),
                version: p.version.clone(),
            },
            package: pkg_label.clone(),
        });

        let started_at = Utc::now();
        let start_instant = Instant::now();

        // First, check if the version is already present.
        if reg.version_exists(&p.name, &p.version)? {
            reporter.info(&format!(
                "{}@{}: already published (skipping)",
                p.name, p.version
            ));
            let skipped = PackageState::Skipped {
                reason: "already published".into(),
            };
            update_state(&mut st, &state_dir, &key, skipped)?;

            // Event: PackageSkipped
            event_log.record(PublishEvent {
                timestamp: Utc::now(),
                event_type: EventType::PackageSkipped {
                    reason: "already published".to_string(),
                },
                package: pkg_label.clone(),
            });
            event_log.write_to_file(&events_path)?;
            event_log.clear();

            let progress = st
                .packages
                .get(&key)
                .context("missing package progress in state for skipped package")?;
            receipts.push(PackageReceipt {
                name: p.name.clone(),
                version: p.version.clone(),
                attempts: progress.attempts,
                state: progress.state.clone(),
                started_at,
                finished_at: Utc::now(),
                duration_ms: start_instant.elapsed().as_millis(),
                evidence: crate::types::PackageEvidence {
                    attempts: vec![],
                    readiness_checks: vec![],
                },
            });
            continue;
        }

crates/shipper/src/types.rs:70

  • The fallback logic for deriving index_base from api_base (lines 67-69) uses simple string replacement, which could produce incorrect URLs in edge cases. For example:
  • http://https://domain.com would become http://index.https://domain.com
  • https://my-https://registry.com would become https://my-index.https://registry.com

Consider using URL parsing (e.g., the url crate) to properly construct the index URL from the API base, or at least add validation that the resulting URL is well-formed.

            // Default: derive from API base (e.g., https://crates.io -> https://index.crates.io)
            self.api_base
                .replace("https://", "https://index.")
                .replace("http://", "http://index.")
        }

Comment on lines +2745 to 2831
fn resume_from_uploaded_skips_cargo_publish_and_reaches_published() {
let td = tempdir().expect("tempdir");
let bin = td.path().join("bin");
write_fake_tools(&bin);
let args_log = td.path().join("cargo_args.txt");
let mut env_vars = fake_program_env_vars(&bin);
env_vars.extend([
("SHIPPER_CARGO_EXIT", Some("0".to_string())),
(
"SHIPPER_CARGO_ARGS_LOG",
Some(args_log.to_str().expect("utf8").to_string()),
),
]);
temp_env::with_vars(env_vars, || {
// Readiness check returns 200 (version is visible)
let server = spawn_registry_server(
std::collections::BTreeMap::from([(
"/api/v1/crates/demo/0.1.0".to_string(),
vec![(404, "{}".to_string())],
),
(
"/api/v1/crates/demo".to_string(),
vec![(404, "{}".to_string())],
vec![(200, "{}".to_string())],
)]),
1,
);

let ws = planned_workspace(td.path(), server.base_url.clone());
let state_dir = td.path().join(".shipper");

// Pre-create state with Uploaded + attempts=1
let mut packages = std::collections::BTreeMap::new();
packages.insert(
"demo@0.1.0".to_string(),
PackageProgress {
name: "demo".to_string(),
version: "0.1.0".to_string(),
attempts: 1,
state: PackageState::Uploaded,
last_updated_at: Utc::now(),
},
);
let st = ExecutionState {
state_version: crate::state::CURRENT_STATE_VERSION.to_string(),
plan_id: ws.plan.plan_id.clone(),
registry: ws.plan.registry.clone(),
created_at: Utc::now(),
updated_at: Utc::now(),
packages,
};
state::save_state(&state_dir, &st).expect("save");

let opts = default_opts(PathBuf::from(".shipper"));
let mut reporter = CollectingReporter::default();
let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");

// Package should reach Published (via version_exists check -> Skipped)
assert_eq!(receipt.packages.len(), 1);
assert!(
matches!(
receipt.packages[0].state,
PackageState::Published | PackageState::Skipped { .. }
),
]),
2,
);
let ws = planned_workspace(td.path(), server.base_url.clone());
let mut opts = default_opts(PathBuf::from(".shipper"));
opts.verify_mode = crate::types::VerifyMode::Package;
"expected Published or Skipped, got {:?}",
receipt.packages[0].state
);

let mut reporter = CollectingReporter::default();
let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
// Cargo publish should NOT have been invoked
// (args_log should not exist or be empty — no cargo publish calls)
let cargo_invoked = args_log.exists()
&& fs::read_to_string(&args_log)
.unwrap_or_default()
.contains("publish");
assert!(
!cargo_invoked,
"cargo publish should not have been invoked on resume from Uploaded"
);

assert!(report.packages[0].dry_run_passed);
assert!(
reporter
.infos
.iter()
.any(|i| i.contains("per-package dry-run"))
);
server.join();
// Verify reporter got the resume message
assert!(
reporter
.infos
.iter()
.any(|i| i.contains("resuming from uploaded")
|| i.contains("already published")
|| i.contains("already complete"))
);

server.join();
});
}

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

The test expects that resuming from Uploaded state will result in either Published or Skipped state (lines 2800-2806), but the logic at line 401 in engine.rs checks version_exists at the very beginning. Since the test sets up the mock registry to return 200 (version exists), the package will be marked as Skipped with reason "already published" before the Uploaded state handling even runs. This means the test doesn't actually validate the resume-from-Uploaded behavior—it validates the skip-if-already-published behavior instead.

To properly test the Uploaded state resume logic, the test should mock the registry to return 404 initially (version doesn't exist), then return 200 after cargo publish would have completed (simulating successful readiness verification). This would exercise the actual "skip cargo publish but run readiness verification" code path.

Copilot uses AI. Check for mistakes.
Comment on lines 121 to 160
@@ -141,196 +159,212 @@ fn publish_package(
));
}

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

When resuming from the Uploaded state in parallel mode, the attempt counter is incremented at line 139 but cargo_succeeded is set to true at line 127. This means that when resuming, the code will:

  1. Enter the retry loop with attempt=1
  2. Skip cargo publish (because cargo_succeeded=true)
  3. Run readiness verification
  4. If readiness fails, increment attempt and sleep/retry

However, the attempts counter in the state will be incremented even though no cargo publish is being retried—only the readiness check is being retried. This could be confusing in the receipt, where attempts might show 2 or 3 when only 1 cargo publish was executed (the original one that created the Uploaded state).

Consider either:

  1. Not incrementing attempts when resuming from Uploaded (preserve the original attempt count)
  2. Or documenting that attempts includes readiness verification retries for resumed publishes

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +146
match child.try_wait().context("failed to poll cargo process")? {
Some(status) => {
let out = child
.wait_with_output()
.context("failed to read cargo output")?;
break (
status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
);

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

After calling try_wait() and getting Some(status), the code calls wait_with_output() on an already-exited child process. This will fail because try_wait() already reaped the process. The stdout/stderr will not be available after try_wait() returns Some.

Instead, read stdout and stderr from the piped handles before the child exits, or use a different approach like storing the handles and reading them after detecting the process has exited.

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +67
let val_offset = eq_offset + 1 + (trimmed.len() - 1 - after_eq.len());
// Check for quoted or unquoted value
if after_eq.starts_with('"') || after_eq.starts_with('\'') {
out = format!("{}= \"[REDACTED]\"", &out[..eq_offset]);
} else if !after_eq.is_empty() {
let _ = val_offset; // suppress unused

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

The val_offset variable is calculated but never used (note the let _ = val_offset; suppression). This calculation appears unnecessary and could be removed for clarity. If it was intended for a future enhancement, consider adding a comment explaining why it's preserved.

Suggested change
let val_offset = eq_offset + 1 + (trimmed.len() - 1 - after_eq.len());
// Check for quoted or unquoted value
if after_eq.starts_with('"') || after_eq.starts_with('\'') {
out = format!("{}= \"[REDACTED]\"", &out[..eq_offset]);
} else if !after_eq.is_empty() {
let _ = val_offset; // suppress unused
// Check for quoted or unquoted value
if after_eq.starts_with('"') || after_eq.starts_with('\'') {
out = format!("{}= \"[REDACTED]\"", &out[..eq_offset]);
} else if !after_eq.is_empty() {

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +71
if let Some(pos) = out.find("token") {
let after_key = &out[pos + "token".len()..];
let trimmed = after_key.trim_start();
if trimmed.starts_with("= ") || trimmed.starts_with("=") {
let eq_offset = pos + "token".len() + (after_key.len() - trimmed.len());
let after_eq = trimmed.trim_start_matches('=').trim_start();
let val_offset = eq_offset + 1 + (trimmed.len() - 1 - after_eq.len());
// Check for quoted or unquoted value
if after_eq.starts_with('"') || after_eq.starts_with('\'') {
out = format!("{}= \"[REDACTED]\"", &out[..eq_offset]);
} else if !after_eq.is_empty() {
let _ = val_offset; // suppress unused
out = format!("{}= [REDACTED]", &out[..eq_offset]);
}
}
}

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

The redaction logic for "token" (line 56) is case-sensitive and only searches for lowercase "token". This means patterns like "Token = value" or "TOKEN = value" won't be redacted. Consider using case-insensitive matching similar to the Authorization header pattern (line 47) to ensure all token patterns are caught.

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: 1abaa5845b

ℹ️ 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".

matches!(
r.state,
PackageState::Published | PackageState::Skipped { .. }
PackageState::Published | PackageState::Uploaded | PackageState::Skipped { .. }

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 Do not classify Uploaded as a successful terminal state

Uploaded is introduced as an intermediate checkpoint, but this success predicate treats it as equivalent to Published. In run_publish, a resumed package can remain Uploaded when its persisted attempts is already at max_attempts (the retry loop is skipped), and this branch then marks the whole execution successful without performing readiness verification. That can produce a false-positive successful publish run even though visibility was never confirmed.

Useful? React with 👍 / 👎.

p.name, p.version, e
));
}
return PackagePublishResult { result: Err(e) };

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 Verify registry before failing timed-out parallel publishes

Parallel mode now passes a per-package timeout into cargo_publish, and timeout expirations surface through this Err(e) path. Returning immediately here skips the registry version_exists reconciliation that non-zero cargo exits get, so a package that uploaded successfully but timed out locally is treated as a hard failure and may be re-attempted on resume (causing duplicate-upload/permanent errors).

Useful? React with 👍 / 👎.

@EffortlessSteven EffortlessSteven merged commit de4c6ee into main Feb 16, 2026
8 of 9 checks passed

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
crates/shipper/src/registry.rs (1)

109-110: 🧹 Nitpick | 🔵 Trivial

Stale comment references removed "2+2+N scheme".

Line 109 still says "using the 2+2+N scheme" but the implementation now follows Cargo's sparse index scheme (1/2/3/first-two). The doc comment on calculate_index_path (Lines 133–140) was updated correctly, but this call-site comment was missed.

✏️ Proposed fix
-        // Calculate the index path for the crate using the 2+2+N scheme
+        // Calculate the index path for the crate using Cargo's sparse index scheme
crates/shipper/src/engine.rs (3)

30-58: 🧹 Nitpick | 🔵 Trivial

PolicyEffects / apply_policy exposure looks fine, but consider containing the surface area.

pub(crate) on PolicyEffects + apply_policy() unblocks reuse (e.g., parallel engine), but making all fields pub(crate) invites ad-hoc coupling across modules. If other modules only need a subset (e.g., readiness gating), consider keeping fields private and adding narrow getters to preserve invariants over time.


161-187: ⚠️ Potential issue | 🟠 Major

Strict ownership + new crate: semantics are OK, but “ownership_verified=false” conflates “skipped” vs “failed”.

In strict ownership mode, skipping owners for is_new_crate is reasonable, but setting ownership_verified = false means downstream consumers can’t distinguish:

  • “couldn’t verify (endpoint doesn’t exist)” vs
  • “verification attempted and failed”.

If this feeds UX/finishability decisions, consider a tri-state (e.g., OwnershipStatus::{Verified, Unverified, NotApplicable}) or add an ownership_check_skipped_reason field.


313-353: ⚠️ Potential issue | 🟠 Major

Uploaded resume flow is solid, but attempt counting + “Uploaded == success” needs a deliberate stance.

Good: PackageState::Uploaded skips cargo publish on resume and drives directly into visibility verification; event emission is gated on cargo_succeeded so you don’t log phantom cargo attempts.

Concerns to address explicitly:

  • Attempts semantics on resume: when cargo_succeeded=true, the loop still increments attempt and persists pr.attempts, even though no cargo attempt occurred. That can make attempts drift away from “cargo publish attempts” and break evidence/attempt alignment.
  • ExecutionFinished success criteria includes Uploaded: treating Uploaded as “success” can mask “uploaded but never observed on registry” if any path leaves a final receipt in Uploaded.

If you intend Uploaded to be strictly intermediate, I’d recommend excluding it from the “all successful” match and/or ensuring every successful completion transitions to Published (even when readiness is disabled).

Also applies to: 364-616, 685-704

crates/shipper-cli/src/main.rs (1)

736-756: 🧹 Nitpick | 🔵 Trivial

Receipt display: Uploaded state added correctly; consider avoiding references to format!() temporaries.

The new Uploaded arm is fine. However the surrounding state_str construction mixes &'static str with &format!(...) references; even if it compiles, it’s easy to regress and hard to read. Prefer Cow<'_, str> (or a String) for the match result.

Proposed refactor (safer state_str construction)
+use std::borrow::Cow;
+
     for p in &receipt.packages {
-        let state_str = match &p.state {
-            shipper::types::PackageState::Published => "\x1b[32mPublished\x1b[0m",
-            shipper::types::PackageState::Pending => "Pending",
-            shipper::types::PackageState::Uploaded => "\x1b[33mUploaded\x1b[0m",
-            shipper::types::PackageState::Skipped { reason } => &format!("Skipped: {}", reason),
-            shipper::types::PackageState::Failed { class, message } => {
-                &format!("\x1b[31mFailed ({:?}): {}\x1b[0m", class, message)
-            }
-            shipper::types::PackageState::Ambiguous { message } => {
-                &format!("\x1b[33mAmbiguous: {}\x1b[0m", message)
-            }
-        };
+        let state_str: Cow<'_, str> = match &p.state {
+            shipper::types::PackageState::Published => Cow::Borrowed("\x1b[32mPublished\x1b[0m"),
+            shipper::types::PackageState::Pending => Cow::Borrowed("Pending"),
+            shipper::types::PackageState::Uploaded => Cow::Borrowed("\x1b[33mUploaded\x1b[0m"),
+            shipper::types::PackageState::Skipped { reason } => {
+                Cow::Owned(format!("Skipped: {}", reason))
+            }
+            shipper::types::PackageState::Failed { class, message } => Cow::Owned(format!(
+                "\x1b[31mFailed ({:?}): {}\x1b[0m",
+                class, message
+            )),
+            shipper::types::PackageState::Ambiguous { message } => {
+                Cow::Owned(format!("\x1b[33mAmbiguous: {}\x1b[0m", message))
+            }
+        };
         println!(
             "  {}@{}: {} (attempts={}, {}ms)",
             p.name, p.version, state_str, p.attempts, p.duration_ms
         );
     }

Please confirm this compiles as-is on your toolchain (or adopt the refactor) since lifetime extension rules around temporaries in match arms are subtle.

crates/shipper/src/engine_parallel.rs (1)

115-137: ⚠️ Potential issue | 🟡 Minor

Parallel resume-from-Uploaded + readiness policy gating are good; revisit attempts semantics when cargo is skipped.

This aligns parallel behavior with sequential:

  • cargo_succeeded gates cargo invocation (so resume doesn’t double-upload).
  • readiness enabled respects PublishPolicy::Fast.

Same semantic concern as sequential: once cargo_succeeded=true, the loop still increments and persists attempt, which may inflate “attempts” without any cargo execution/evidence. If attempts are intended to mean “cargo attempts”, consider decoupling readiness retries from the attempts counter (or introduce a separate readiness retry counter).

Also applies to: 162-318, 319-368

crates/shipper/src/cargo.rs (2)

315-380: 🧹 Nitpick | 🔵 Trivial

Tests updated for new signature + good redaction coverage; consider adding 1–2 extra cases.

Nice: redaction tests cover bearer headers + env var tokens + multi-line handling, and the existing publish flag tests now pass timeout: None.

Extra cases worth adding (quick wins):

  • unquoted token=cio_xxx (no spaces)
  • Authorization: Bearer\t<token> (non-space whitespace)

As per coding guidelines, "Use #[serial] from serial_test to isolate tests that mutate environment variables or filesystem".

Also applies to: 438-497


100-170: ⚠️ Potential issue | 🟠 Major

Fix pipe deadlock risk in timeout implementation that affects parallel publishing.

The timeout path (lines 127-160) spawns cargo with piped stdout/stderr, then polls with try_wait() in a loop without draining the pipes. If cargo produces verbose output (typical for real workspaces), the OS pipe buffer fills, cargo blocks on write, and try_wait() never returns—you hit the timeout even though cargo is progressing. This risk is critical for parallel mode, which calls cargo_publish() with Some(per_package_timeout) (default 30 minutes).

No existing tests exercise the timeout path with actual cargo output; all tests pass timeout=None.

Safer approach: spawn reader threads to drain stdout/stderr into buffers (or keep only tail) while the main thread enforces the timeout, similar to how the timeout=None path uses cmd.output() internally.

🤖 Fix all issues with AI agents
In `@crates/shipper/src/cargo.rs`:
- Around line 16-98: The redact_line function contains an unused val_offset
variable and suppression; remove val_offset and the unused suppression and
simplify the "token" assignment branch in redact_line to only compute eq_offset
and construct the redacted output (no unused locals). Keep the existing matching
behavior for token assignments as-is unless you have evidence of "token:" style
leaks—if you do, add a small extra branch in redact_line to treat a colon (:)
similarly to '=' when trimming after the "token" key. Ensure references:
redact_line, find_cargo_token_env, and redact_sensitive remain unchanged
otherwise.

In `@crates/shipper/src/engine.rs`:
- Around line 972-993: The test comment/expectation in
resume_from_uploaded_skips_cargo_publish_and_reaches_published is ambiguous
about the terminal state: when version_exists returns 200 the crate should be
treated as already published ("Skipped") not "Published". Update the test to
assert the concrete invariants — e.g., that cargo publish was not invoked and
the run completes without error and reaches a terminal "Skipped" state — and
update the assertion/messages accordingly; locate the test by the function name
resume_from_uploaded_skips_cargo_publish_and_reaches_published and ensure any
logging/assertion text and expectations are tightened to "Skipped" (or
equivalent terminal enum/state) rather than "Published". Ensure this aligns with
the existing env setup helpers with_test_env and fake_program_env_vars so the
test remains isolated.

In `@crates/shipper/src/plan.rs`:
- Around line 388-407: The test named
build_plan_allows_dev_dep_on_non_publishable is misleading because its fixture
makes alpha dev-depend on a publishable package; update the test fixture so
alpha dev-depends on a non-publishable member (e.g., change the dependency to
point to "c" which has publish = false) or rename the test to match the current
fixture; search for build_plan_allows_dev_dep_on_non_publishable and associated
test fixtures (and the create_workspace/create_workspace_with_npdep helpers) and
modify the dependency entry for alpha to target "c" (or alternatively rename the
test) consistently in the other test occurrences mentioned.
- Around line 141-172: Collect all offending dependency edges instead of bailing
on the first one: walk the same loops (over resolve.nodes, included, node.deps
and checking publishable/workspace_ids and dep.dep_kinds) but push a formatted
violation string into a Vec when is_normal_or_build is true instead of calling
bail immediately; include the source package name (from pkg_map.get(&node.id)),
the dependent package name (from pkg_map.get(&dep.pkg)), the dependency kind
(derive from dep.dep_kinds, e.g., "normal" or "build") and the
workspace/registry identifier (from the pkg_map/pkg metadata) in each message;
after the loops, if the Vec is non-empty, call bail once with a joined error
message listing all violations.

In `@crates/shipper/src/types.rs`:
- Around line 927-935: Test helper calculate_index_path_for_crate is using
to_lowercase() which diverges from production calculate_index_path; change
calculate_index_path_for_crate to use to_ascii_lowercase() instead (so the
crate-name canonicalization matches production), and likewise update
index_path_follows_pattern to use to_ascii_lowercase() where it currently calls
to_lowercase(); keep the same slicing/formatting logic but operate on the
ASCII-lowercased string.

Comment on lines 16 to 98
fn tail_lines(s: &str, n: usize) -> String {
let lines: Vec<&str> = s.lines().collect();
if lines.len() <= n {
let tail = if lines.len() <= n {
s.to_string()
} else {
lines[lines.len() - n..].join("\n")
};
redact_sensitive(&tail)
}

/// Redact sensitive patterns (tokens, credentials) from output strings.
/// Applied to stdout/stderr tails before they are stored in receipts and event logs.
pub(crate) fn redact_sensitive(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for line in s.lines() {
if !result.is_empty() {
result.push('\n');
}
result.push_str(&redact_line(line));
}
// Preserve trailing newline if present
if s.ends_with('\n') {
result.push('\n');
}
result
}

fn redact_line(line: &str) -> String {
let mut out = line.to_string();

// Authorization: Bearer <token>
if let Some(pos) = out.to_lowercase().find("authorization:") {
let after = &out[pos..];
if let Some(bearer_pos) = after.to_lowercase().find("bearer ") {
let redact_start = pos + bearer_pos + "bearer ".len();
out = format!("{}[REDACTED]", &out[..redact_start]);
}
}

// token = "<value>" or token = '<value>' or token = <value>
if let Some(pos) = out.find("token") {
let after_key = &out[pos + "token".len()..];
let trimmed = after_key.trim_start();
if trimmed.starts_with("= ") || trimmed.starts_with("=") {
let eq_offset = pos + "token".len() + (after_key.len() - trimmed.len());
let after_eq = trimmed.trim_start_matches('=').trim_start();
let val_offset = eq_offset + 1 + (trimmed.len() - 1 - after_eq.len());
// Check for quoted or unquoted value
if after_eq.starts_with('"') || after_eq.starts_with('\'') {
out = format!("{}= \"[REDACTED]\"", &out[..eq_offset]);
} else if !after_eq.is_empty() {
let _ = val_offset; // suppress unused
out = format!("{}= [REDACTED]", &out[..eq_offset]);
}
}
}

// CARGO_REGISTRY_TOKEN=<value> and CARGO_REGISTRIES_<NAME>_TOKEN=<value>
if let Some(pos) = find_cargo_token_env(&out)
&& let Some(eq_pos) = out[pos..].find('=')
{
let abs_eq = pos + eq_pos;
out = format!("{}=[REDACTED]", &out[..abs_eq]);
}

out
}

/// Find the start position of a CARGO_REGISTRY_TOKEN or CARGO_REGISTRIES_<NAME>_TOKEN pattern.
fn find_cargo_token_env(s: &str) -> Option<usize> {
// Check for CARGO_REGISTRY_TOKEN
if let Some(pos) = s.find("CARGO_REGISTRY_TOKEN") {
return Some(pos);
}
// Check for CARGO_REGISTRIES_<NAME>_TOKEN
if let Some(pos) = s.find("CARGO_REGISTRIES_") {
let after = &s[pos + "CARGO_REGISTRIES_".len()..];
if after.contains("_TOKEN") {
return Some(pos);
}
}
None
}

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

Redaction is correctly wired into tails; tighten patterns and remove dead code.

Wiring redact_sensitive() into tail_lines() is the right “defense-in-depth” point (single choke point for receipts/events).

Two concrete cleanups:

  • val_offset is computed but unused (then explicitly suppressed). Drop it to reduce confusion.
  • Consider extending token assignment matching beyond token = ... (e.g., token:) only if you’ve observed leaks in real cargo output; otherwise current scope is OK.
🤖 Prompt for AI Agents
In `@crates/shipper/src/cargo.rs` around lines 16 - 98, The redact_line function
contains an unused val_offset variable and suppression; remove val_offset and
the unused suppression and simplify the "token" assignment branch in redact_line
to only compute eq_offset and construct the redacted output (no unused locals).
Keep the existing matching behavior for token assignments as-is unless you have
evidence of "token:" style leaks—if you do, add a small extra branch in
redact_line to treat a colon (:) similarly to '=' when trimming after the
"token" key. Ensure references: redact_line, find_cargo_token_env, and
redact_sensitive remain unchanged otherwise.

Comment on lines +972 to 993
fn fake_program_env_vars(bin_dir: &Path) -> Vec<(&'static str, Option<String>)> {
vec![
(
"SHIPPER_CARGO_BIN",
Some(fake_cargo_path(bin_dir).to_str().expect("utf8").to_string()),
),
(
"SHIPPER_GIT_BIN",
Some(fake_git_path(bin_dir).to_str().expect("utf8").to_string()),
),
]
}

/// Build a combined env var list from fake programs + additional vars, then run closure.
fn with_test_env<F, R>(bin_dir: &Path, extra: Vec<(&'static str, Option<String>)>, f: F) -> R
where
F: FnOnce() -> R,
{
let mut vars = fake_program_env_vars(bin_dir);
vars.extend(extra);
temp_env::with_vars(vars, f)
}

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 env isolation refactor + resume-from-Uploaded coverage look good.

  • with_test_env() + fake_program_env_vars() makes env setup consistent and reduces leakage risk.
  • Tests that mutate env are consistently #[serial], which avoids cross-test flakiness.

Minor nit: in resume_from_uploaded_skips_cargo_publish_and_reaches_published, the comment/expectation mixes “Published” and “Skipped” (given version_exists returns 200, “Skipped” is the likely state). Consider tightening to the intended invariant (e.g., “must not call cargo publish; must not error; must reach a terminal state”).
As per coding guidelines, "Use #[serial] from serial_test to isolate tests that mutate environment variables or filesystem".

Also applies to: 1350-1578, 1580-2742, 2743-2831

🤖 Prompt for AI Agents
In `@crates/shipper/src/engine.rs` around lines 972 - 993, The test
comment/expectation in
resume_from_uploaded_skips_cargo_publish_and_reaches_published is ambiguous
about the terminal state: when version_exists returns 200 the crate should be
treated as already published ("Skipped") not "Published". Update the test to
assert the concrete invariants — e.g., that cargo publish was not invoked and
the run completes without error and reaches a terminal "Skipped" state — and
update the assertion/messages accordingly; locate the test by the function name
resume_from_uploaded_skips_cargo_publish_and_reaches_published and ensure any
logging/assertion text and expectations are tightened to "Skipped" (or
equivalent terminal enum/state) rather than "Published". Ensure this aligns with
the existing env setup helpers with_test_env and fake_program_env_vars so the
test remains isolated.

Comment on lines +141 to +172
// Validate: included crates must not have normal/build deps on non-publishable workspace members.
for node in &resolve.nodes {
if !included.contains(&node.id) {
continue;
}
for dep in &node.deps {
// Skip deps that are publishable or not workspace members
if publishable.contains(&dep.pkg) || !workspace_ids.contains(&dep.pkg) {
continue;
}
let is_normal_or_build = dep
.dep_kinds
.iter()
.any(|k| matches!(k.kind, DependencyKind::Normal | DependencyKind::Build));
if is_normal_or_build {
let pkg_name = pkg_map
.get(&node.id)
.map(|p| p.name.as_str())
.unwrap_or("unknown");
let dep_name = pkg_map
.get(&dep.pkg)
.map(|p| p.name.as_str())
.unwrap_or("unknown");
bail!(
"publishable package '{}' depends on non-publishable workspace member '{}'",
pkg_name,
dep_name
);
}
}
}

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

Dependency validation is the right guardrail; consider reporting all violations at once.

Catching normal/build deps from included publishable crates onto non-publishable workspace members is exactly the “fail fast” you want.

Two improvements that would materially help UX:

  • Aggregate all offending edges and report them together (avoid fix-one-run-again loops).
  • Include the dependency kind (normal/build) and maybe the workspace registry name in the error message.
🤖 Prompt for AI Agents
In `@crates/shipper/src/plan.rs` around lines 141 - 172, Collect all offending
dependency edges instead of bailing on the first one: walk the same loops (over
resolve.nodes, included, node.deps and checking publishable/workspace_ids and
dep.dep_kinds) but push a formatted violation string into a Vec when
is_normal_or_build is true instead of calling bail immediately; include the
source package name (from pkg_map.get(&node.id)), the dependent package name
(from pkg_map.get(&dep.pkg)), the dependency kind (derive from dep.dep_kinds,
e.g., "normal" or "build") and the workspace/registry identifier (from the
pkg_map/pkg metadata) in each message; after the loops, if the Vec is non-empty,
call bail once with a joined error message listing all violations.

Comment on lines 388 to 407
fn create_workspace(root: &Path) {
create_workspace_with_npdep(root, false);
}

fn create_workspace_with_npdep(root: &Path, include_npdep: bool) {
let members = if include_npdep {
r#"members = ["a", "b", "c", "d", "zeta", "alpha", "npdep"]"#
} else {
r#"members = ["a", "b", "c", "d", "zeta", "alpha"]"#
};
write_file(
&root.join("Cargo.toml"),
r#"
&format!(
r#"
[workspace]
members = ["a", "b", "c", "d", "zeta", "alpha", "npdep"]
{members}
resolver = "2"
"#,
"#
),
);

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

Tests cover the new validation well; one test name/fixture doesn’t match what it claims.

build_plan_rejects_publishable_depending_on_non_publishable and the selection test are good coverage.

But build_plan_allows_dev_dep_on_non_publishable doesn’t actually set up a dev-dependency on a non-publishable member (it currently uses alpha dev-depending on a, which is publishable). Either rename it or adjust the fixture to dev-depend on c (publish=false) so it truly asserts the intended “dev-deps are allowed” rule.

Also applies to: 483-498, 528-579

🤖 Prompt for AI Agents
In `@crates/shipper/src/plan.rs` around lines 388 - 407, The test named
build_plan_allows_dev_dep_on_non_publishable is misleading because its fixture
makes alpha dev-depend on a publishable package; update the test fixture so
alpha dev-depends on a non-publishable member (e.g., change the dependency to
point to "c" which has publish = false) or rename the test to match the current
fixture; search for build_plan_allows_dev_dep_on_non_publishable and associated
test fixtures (and the create_workspace/create_workspace_with_npdep helpers) and
modify the dependency entry for alpha to target "c" (or alternatively rename the
test) consistently in the other test occurrences mentioned.

Comment on lines 927 to 935
fn calculate_index_path_for_crate(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 { '_' };

let first = if first.is_alphanumeric() { first } else { '_' };
let second = if second.is_alphanumeric() {
second
} else {
'_'
};

format!("{}/{}/{}", first, second, crate_name)
let lower = crate_name.to_lowercase();
match lower.len() {
1 => format!("1/{}", lower),
2 => format!("2/{}", lower),
3 => format!("3/{}/{}", &lower[..1], lower),
_ => format!("{}/{}/{}", &lower[..2], &lower[2..4], lower),
}
}

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

Test helper uses to_lowercase() while production code uses to_ascii_lowercase().

calculate_index_path_for_crate in this test helper (Line 928) uses to_lowercase(), but the production calculate_index_path in registry.rs (Line 142) uses to_ascii_lowercase(). These behave differently for non-ASCII input (e.g., Unicode case folding). The proptest regex only generates ASCII, so it won't catch this divergence, but the helper should mirror production exactly.

♻️ Proposed fix
     fn calculate_index_path_for_crate(crate_name: &str) -> String {
-        let lower = crate_name.to_lowercase();
+        let lower = crate_name.to_ascii_lowercase();
         match lower.len() {

Also at Line 889 in index_path_follows_pattern:

-            let lower = crate_name.to_lowercase();
+            let lower = crate_name.to_ascii_lowercase();
🤖 Prompt for AI Agents
In `@crates/shipper/src/types.rs` around lines 927 - 935, Test helper
calculate_index_path_for_crate is using to_lowercase() which diverges from
production calculate_index_path; change calculate_index_path_for_crate to use
to_ascii_lowercase() instead (so the crate-name canonicalization matches
production), and likewise update index_path_follows_pattern to use
to_ascii_lowercase() where it currently calls to_lowercase(); keep the same
slicing/formatting logic but operate on the ASCII-lowercased string.

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