BugCI/CD

cargo build in Release and Deploy Workflows Missing –locked, Allowing Dependency Drift from CI-Validated Lockfile

The release.yml and deploy-testnet.yml GitHub Actions workflows invoke cargo build without the --locked flag, while every Cargo invocation in ci.yml explicitly passes --locked.

Rootlock SRE Engine 5 min read
Diagnostic brief

At a Glance

The release.yml and deploy-testnet.yml GitHub Actions workflows invoke cargo build without the --locked flag, while every Cargo invocation in ci.yml explicitly passes --locked.

Severity Not rated
Confidence Medium
Frequency Unknown
Impact See analysis

Summary

The release.yml and deploy-testnet.yml GitHub Actions workflows invoke cargo build without the --locked flag, while every Cargo invocation in ci.yml explicitly passes --locked. This allows Cargo to silently resolve a dependency graph that differs from the one committed in Cargo.lock and validated by CI, meaning the WASM binaries shipped to users and recorded by SHA256 checksum in release notes may have never been exercised by the test suite. Build reproducibility is also broken: rebuilding the same Git tag at a later date can produce different checksums.

Root-Cause Analysis

Confirmed evidence:

  • ci.yml passes --locked on all three of its Cargo invocations (clippy, build, test).
  • release.yml's Build Release Contracts step omits --locked:
  run: cargo build --target wasm32v1-none --release
  • deploy-testnet.yml's Build Contracts step omits --locked in the same way.
  • deploy-testnet.yml already uses --locked for cargo install stellar-cli, proving awareness of the flag and making the omission in the project's own build steps an oversight rather than a deliberate policy.
  • Issue #63 previously introduced --locked into CI to resolve a lockfile pinning incident, but the release path was not updated at the same time.

Confirmed impact:

Without --locked, Cargo consults the network resolver and may select dependency versions that satisfy the version constraints in Cargo.toml but differ from the exact versions pinned in Cargo.lock. This is especially likely after a dependency publishes a patch release between CI and a release build.

Reasonable inference:

The omission is an oversight introduced when the release and deploy workflows were written independently of — or before — issue #63 hardened ci.yml. There is no evidence this was intentional.

Security dimension (label: security):

The published WASM binaries are the authoritative on-chain artifacts. Their SHA256 checksums are the only integrity reference users have. If the resolved dependency set differs from what CI tested, a supply-chain substitution in a transitive dependency could reach production without any test coverage detecting it.

Resolution Steps

  1. Verify Cargo.lock is committed and current before applying the flag. Run cargo build --target wasm32v1-none --release locally and confirm Cargo.lock is unchanged (no diff). Commit any needed updates first, because once --locked is enforced a stale lockfile will fail the build immediately.
  1. Add --locked to release.yml in the Build Release Contracts step.
  1. Add --locked to deploy-testnet.yml in the Build Contracts step.
  1. Audit all remaining Cargo invocations across every workflow file to confirm uniform use of --locked. Pay attention to any cargo generate-lockfile, cargo update, or cargo install steps for project crates.
  1. Open a dry-run PR with only the flag additions and verify the release workflow succeeds end-to-end against the current committed lockfile before merging.
  1. Validate failure behavior by deliberately making Cargo.lock stale (see CLI Commands) and confirming the build exits non-zero with the expected Cargo error.

CLI Commands

Check for local lockfile drift before enabling --locked:

cargo build --target wasm32v1-none --release
git diff Cargo.lock

If the diff is non-empty, update and commit the lockfile:

git add Cargo.lock
git commit -m "chore: update Cargo.lock before enforcing --locked in release workflow"

Simulate a stale lockfile to verify failure behavior:

# Add a harmless version bump to a dependency in Cargo.toml,
# then attempt a --locked build to confirm it fails.
cargo build --locked --target wasm32v1-none --release

Expected error output when the lockfile is stale:

error: the lock file /path/to/Cargo.lock needs to be updated but --locked was passed to prevent this

Audit all workflow files for Cargo invocations missing --locked:

grep -rn "cargo " .github/workflows/ | grep -v "\-\-locked"

Review every line returned; not all Cargo subcommands require --locked (e.g., cargo install for external tools may be intentionally pinned separately), but all project build, test, and lint steps should use it.

Configuration Snippets

release.yml — corrected Build Release Contracts step:

- name: Build Release Contracts
  run: cargo build --locked --target wasm32v1-none --release

deploy-testnet.yml — corrected Build Contracts step:

- name: Build Contracts
  run: cargo build --locked --target wasm32v1-none --release

Reference pattern from ci.yml (no changes needed):

run: cargo clippy --locked --all-targets --all-features --target wasm32v1-none -- -D warnings
run: cargo build --locked --target wasm32v1-none --release
run: cargo test --locked --verbose

Verification

After applying the fix, confirm the release workflow succeeds:

  1. Merge the corrected workflow files.
  2. Trigger the release workflow (either via tag push or manual dispatch).
  3. Confirm the build step exits with code 0 and produces WASM artifacts.

Confirm artifact integrity:

Build the same commit locally with --locked and compare SHA256 checksums against the CI-produced artifacts:

cargo build --locked --target wasm32v1-none --release
sha256sum target/wasm32v1-none/release/<CONTRACT_NAME>.wasm

The digest must match the value recorded in the release notes.

Confirm stale-lockfile failure (regression gate):

In a branch, manually edit one version specifier in Cargo.toml without running cargo update, then trigger the workflow. The build must fail with Cargo's --locked error and must not produce any artifact.

Audit command — expected output after fix:

grep -rn "cargo build\|cargo test\|cargo clippy" .github/workflows/ | grep -v "\-\-locked"

This command should return no output after the fix is applied.

Prevention

Enforce lockfile commitment in CI:

Add a step to ci.yml that fails if Cargo.lock would be modified by any build step, catching drift before it reaches the release path:

- name: Verify Cargo.lock is up to date
  run: |
    cargo update --dry-run 2>&1 | grep -q "Updating" && \
      echo "Cargo.lock is stale; run cargo update and commit the result" && exit 1 || true

Repository setting — require Cargo.lock to be committed:

Ensure .gitignore does not exclude Cargo.lock for binary or WASM crates. For library crates this is a matter of policy; for deployable artifacts, committing Cargo.lock is mandatory.

Branch protection / PR check:

Add a required status check that runs git diff --exit-code Cargo.lock after cargo build --locked to prevent merging PRs that leave the lockfile dirty.

Monitoring — release artifact digest tracking:

Record the SHA256 of each published WASM binary in a machine-readable artifact manifest (e.g., a checksums.json committed to the release). A downstream script can re-build from the same tag with --locked and assert digest equality, catching any reproducibility regression automatically.

Periodic cargo audit in CI:

- name: Security audit
  run: |
    cargo install --locked cargo-audit
    cargo audit --locked

This complements --locked by alerting on known-vulnerable dependency versions pinned in the lockfile, closing the loop between lockfile integrity and supply-chain security.

Developer FirstBuilt for engineers solving real problems
Evidence DrivenTechnical claims tied to available evidence
Automation ReadyStructured for CLI, APIs, and workflows
Privacy FocusedNo unnecessary data collection in this article UI
STAY AHEAD OF ISSUES

Get new root-cause analyses in your inbox

Engineering-focused updates. No fake subscriber counts. Unsubscribe anytime.