Skip to content

fix: resolve the MSRV lockfile up front so no committed Cargo.lock is required #12

fix: resolve the MSRV lockfile up front so no committed Cargo.lock is required

fix: resolve the MSRV lockfile up front so no committed Cargo.lock is required #12

Workflow file for this run

name: selftest
# Exercise every composite action end-to-end against the fixture crate, so the
# actions are verified before any consumer relies on them.
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
selftest:
name: exercise the composite actions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: install-toolchain
uses: ./.github/actions/install-toolchain
with:
components: rustfmt clippy
# rust-cache: warms the cargo cache + sets CARGO_INCREMENTAL=0 for the build steps below.
- name: rust-cache (fixture)
uses: ./.github/actions/rust-cache
with:
prefix: fixture
cache-target: "true"
target-dir: fixtures/sample-crate/target
lockfiles: fixtures/sample-crate/Cargo.lock
# Prove the env export crosses into a later step (the build inherits incremental=off).
- name: rust-cache exported CARGO_INCREMENTAL=0
run: test "$CARGO_INCREMENTAL" = "0"
- name: lint-and-test (fixture)
uses: ./.github/actions/lint-and-test
with:
working-directory: fixtures/sample-crate
- name: check-release-readiness (fixture)
uses: ./.github/actions/check-release-readiness
with:
working-directory: fixtures/sample-crate
expected-version: "0.1.0"
# cargo-install + cargo-use in HOST mode (docker:false): wrap a plain
# `cargo install` on the runner, then run the tool from the shared bin dir.
- name: cargo-install (host) a CLI crate into a shared cache
uses: ./.github/actions/cargo-install
with:
docker: "false"
tool: npm-utils
args: "--features cli"
locked: "false" # probe tests the action plumbing, not --locked semantics
cargo-cache: .cargo-tools-host
- name: cargo-use (host) runs the installed tool
uses: ./.github/actions/cargo-use
with:
docker: "false"
cargo-cache: .cargo-tools-host
args: "npm-utils --version"
# Negative control: a malicious tool name is rejected on the runner by the
# cargo-install allowlist before any shell runs it — no command injection.
- name: cargo-install rejects a malicious tool name (negative control)
id: evil_tool
continue-on-error: true
uses: ./.github/actions/cargo-install
with:
docker: "false"
tool: "evil; touch /tmp/cicd-pwned"
- name: assert the injection was rejected
run: |
if [ "${{ steps.evil_tool.outcome }}" != "failure" ]; then
echo "::error::malicious tool name was not rejected"; exit 1
fi
if [ -e /tmp/cicd-pwned ]; then
echo "::error::injection executed: /tmp/cicd-pwned exists"; exit 1
fi
echo "✓ malicious tool name rejected; no injection"
# The Docker action set: build the image locally (no GHCR dependency to prove)
# and run fetch (networked) -> lint/test (sealed, --network none) -> readiness
# (networked) against the fixture.
selftest-docker:
name: exercise the Docker actions
runs-on: ubuntu-latest
# CICD_PROBE rides the runner env so the env-passthrough proofs below can
# (conditionally) forward it into the container; the expectation is supplied
# per step via the literal `env` input (CICD_EXPECT) so it always crosses.
env:
CICD_PROBE: forwarded
steps:
- uses: actions/checkout@v6
- name: build-image (local, cached)
uses: ./.github/actions/build-image
with:
tag: rust-ci:selftest
cache: "true"
# Negative control: the sealing flag seal_run applies (--network=none) really
# blocks egress — a curl from inside a sealed container must fail.
- name: sealed network is blocked (negative control)
run: |
if docker run --rm --network=none --cap-drop=ALL --security-opt=no-new-privileges \
rust-ci:selftest bash -c 'curl -sSf --max-time 5 https://crates.io >/dev/null 2>&1'; then
echo "::error::network reachable inside a --network=none container"; exit 1
fi
echo "✓ --network=none blocks network egress"
- name: cargo-fetch (networked)
uses: ./.github/actions/cargo-fetch
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
# cargo-docker: a sealed build off the warmed cache (exercises the low-level wrapper).
- name: cargo-docker (sealed build)
uses: ./.github/actions/cargo-docker
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
args: "build --locked"
- name: lint-and-test (sealed, --network none) + env passthrough
uses: ./.github/actions/lint-and-test-docker
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
# Positive proof: env-include forwards CICD_PROBE across the sealed
# boundary; CICD_EXPECT=present makes the fixture assert it arrived.
env-include: "CICD_.*"
env: |
CICD_EXPECT=present
# Negative control: the DEFAULT include (CARGO_.*) must NOT forward an
# unrelated host var. CICD_PROBE is in the runner env but should not cross.
- name: lint-and-test (default include does not leak)
uses: ./.github/actions/lint-and-test-docker
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
env: |
CICD_EXPECT=absent
# Prove the no-warmup path: networked, a cold cache, no cargo-fetch before it.
- name: lint-and-test (networked, no warmup) + exclusion wins
uses: ./.github/actions/lint-and-test-docker
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
offline: "false"
cargo-cache: .cargo-cache-online
target-dir: target-online
# Exclusion wins: CICD_PROBE matches env-include but is also in
# env-exclude, so it must NOT cross (CICD_EXPECT=absent asserts that).
env-include: "CICD_.*"
env-exclude: "CARGO_HOME|RUSTUP_HOME|CARGO_TARGET_DIR|CICD_PROBE"
env: |
CICD_EXPECT=absent
- name: publish-dry-run (networked prep, sealed verify-build)
uses: ./.github/actions/publish-dry-run
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
expected-version: "0.1.0"
# cargo-install + cargo-use: install a real CLI crate into a SHARED cache in
# one sealed container, then run it from a SEPARATE sealed, offline container.
# Proves an installed tool persists in the host-mounted CARGO_HOME and resolves
# via the prepended bin dir — including the `cargo <subcommand>` form. npm-utils
# is pure-Rust (no openssl) and gates its binaries behind the `cli` feature, so
# this also exercises the free-form `args` pass-through.
- name: cargo-install (sealed) a CLI crate into the shared cache
uses: ./.github/actions/cargo-install
with:
image: rust-ci:selftest
tool: npm-utils
args: "--features cli"
locked: "false" # probe tests the action plumbing, not --locked semantics
cargo-cache: .cargo-tools
- name: both installed binaries landed in the shared host dir
run: test -x .cargo-tools/bin/npm-utils && test -x .cargo-tools/bin/cargo-npm-utils
- name: cargo-use (sealed, offline) runs the installed cargo subcommand
uses: ./.github/actions/cargo-use
with:
image: rust-ci:selftest
cargo-cache: .cargo-tools
offline: "true"
args: "cargo npm-utils --help"
# Negative control: cargo-use offline really seals the network — a tool that
# reaches out must fail (proves the action wires OFFLINE -> --network=none).
- name: cargo-use offline blocks egress (negative control)
id: use_offline_egress
continue-on-error: true
uses: ./.github/actions/cargo-use
with:
image: rust-ci:selftest
cargo-cache: .cargo-tools
offline: "true"
args: "curl -sSf --max-time 5 https://crates.io"
- name: assert cargo-use sealed the network
run: |
if [ "${{ steps.use_offline_egress.outcome }}" != "failure" ]; then
echo "::error::cargo-use offline did not block network egress"; exit 1
fi
echo "✓ cargo-use offline sealed the network"
# build-image `msrv` sentinel: build at the fixture's DECLARED rust-version, read
# from Cargo.toml (no hand-managed version), so a consumer can run the whole Docker
# pipeline on the support floor. The fixture pins 1.82.
- name: build-image resolves the msrv sentinel from Cargo.toml
uses: ./.github/actions/build-image
with:
rust-version: msrv
working-directory: fixtures/sample-crate
tag: rust-ci:bi-msrv
- name: the sentinel image IS the declared MSRV (1.82)
run: |
v="$(docker run --rm rust-ci:bi-msrv rustc --version)"
echo "$v"
case "$v" in
*" 1.82"*) echo "✓ sentinel image is rust 1.82" ;;
*) echo "::error::sentinel image is not rust 1.82: $v"; exit 1 ;;
esac
# Negative control: a non-numeric Cargo.toml rust-version is rejected by the shared
# resolver before it can become a docker tag — proving build-image wires the
# validation into the sentinel path (the smuggled command must not run).
- name: craft a bad-MSRV manifest (for the negative control)
run: |
mkdir -p "$RUNNER_TEMP/bad-msrv"
printf '[package]\nname = "bad"\nversion = "0.0.0"\nrust-version = "1.0; touch pwned"\n' \
> "$RUNNER_TEMP/bad-msrv/Cargo.toml"
- name: build-image rejects a non-numeric declared rust-version (negative control)
id: bi_bad
continue-on-error: true
uses: ./.github/actions/build-image
with:
rust-version: msrv
working-directory: ${{ runner.temp }}/bad-msrv
tag: rust-ci:bi-bad
- name: assert the bad declared rust-version was rejected
run: |
if [ "${{ steps.bi_bad.outcome }}" != "failure" ]; then
echo "::error::build-image accepted a non-numeric rust-version"; exit 1
fi
test ! -e "$RUNNER_TEMP/bad-msrv/pwned"
echo "✓ build-image rejected a non-numeric declared rust-version"
# msrv: build an image at the fixture's DECLARED rust-version and cargo check there.
# Exercises the Cargo.toml extraction, the image-at-MSRV build, and the sealed check.
# locked: true takes the committed-lockfile path — the fixture pins one.
- name: msrv (image at the declared MSRV, sealed check)
uses: ./.github/actions/msrv
with:
working-directory: fixtures/sample-crate
package: cicd-rust-selftest-fixture
locked: "true"
# Negative control: a non-numeric rust-version is rejected before it can reach a docker
# tag or shell (proves the input validation; the smuggled command must not run).
- name: msrv rejects a non-numeric rust-version (negative control)
id: msrv_bad
continue-on-error: true
uses: ./.github/actions/msrv
with:
working-directory: fixtures/sample-crate
rust-version: "latest; touch pwned"
- name: assert the bad rust-version was rejected
run: |
if [ "${{ steps.msrv_bad.outcome }}" != "failure" ]; then
echo "::error::msrv accepted a non-numeric rust-version"; exit 1
fi
test ! -e fixtures/sample-crate/pwned
echo "✓ msrv rejected a non-numeric rust-version"
# A crate with NO committed lockfile must still work: the source is mounted read-only for the
# sealed check, so the action resolves the lock up front rather than failing to write it there.
- name: stage a copy of the fixture with no Cargo.lock
run: |
rm -rf "$RUNNER_TEMP/fixture-nolock"
mkdir -p "$RUNNER_TEMP/fixture-nolock"
rsync -a --exclude='target*' --exclude='Cargo.lock' fixtures/sample-crate/ "$RUNNER_TEMP/fixture-nolock/"
test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock"
# Negative control: locked: true demands a committed lockfile, so a crate without one fails
# with a clear up-front error — not the cryptic read-only-filesystem failure of writing it in-container.
- name: msrv (locked true) rejects a crate with no committed lockfile
id: msrv_nolock_locked
continue-on-error: true
uses: ./.github/actions/msrv
with:
working-directory: ${{ runner.temp }}/fixture-nolock
package: cicd-rust-selftest-fixture
locked: "true"
- name: assert the missing lockfile was rejected
run: |
if [ "${{ steps.msrv_nolock_locked.outcome }}" != "failure" ]; then
echo "::error::msrv locked:true accepted a crate with no Cargo.lock"; exit 1
fi
test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock"
echo "✓ msrv locked:true requires a committed lockfile"
# Default (locked: false): resolve a fresh lockfile at the MSRV up front, then check it sealed.
- name: msrv (locked false) resolves a lockfile when none is committed
uses: ./.github/actions/msrv
with:
working-directory: ${{ runner.temp }}/fixture-nolock
package: cicd-rust-selftest-fixture
# The resolution happens in a disposable copy, so the lockless checkout must STAY lockless —
# the action may not leave an untracked Cargo.lock behind.
- name: assert the lockless checkout stayed lockless
run: |
test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock"
echo "✓ msrv locked:false left no Cargo.lock behind"
# locked: false must not rewrite a committed lockfile either: fresh resolution happens in
# the disposable copy while the checkout's Cargo.lock stays byte-identical.
- name: snapshot the committed lockfile
run: cp fixtures/sample-crate/Cargo.lock "$RUNNER_TEMP/Cargo.lock.before"
- name: msrv (locked false) resolves fresh without touching the committed lockfile
uses: ./.github/actions/msrv
with:
working-directory: fixtures/sample-crate
package: cicd-rust-selftest-fixture
- name: assert the committed lockfile is byte-identical
run: |
cmp "$RUNNER_TEMP/Cargo.lock.before" fixtures/sample-crate/Cargo.lock
echo "✓ msrv locked:false preserved the committed Cargo.lock byte-for-byte"
# install-toolchain must also work on Windows. windows-latest exercises the rustup-present path;
# windows-11-arm ships no Rust at all, exercising the rustup-init.exe bootstrap. Building the
# fixture proves the toolchain is usable, not merely installed (and, on arm64, that the image
# provides the MSVC arm64 link tools).
selftest-windows:
name: install-toolchain · ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
runner: [windows-latest, windows-11-arm]
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/install-toolchain
with:
components: clippy
- name: toolchain is installed + on PATH
shell: bash
run: rustc --version && cargo --version && cargo clippy --version
- name: build the fixture
shell: bash
working-directory: fixtures/sample-crate
run: cargo build --locked
# rust-cache must OBEY an explicit CARGO_INCREMENTAL (not force it off): set it at job level and
# confirm the action leaves it untouched for the build steps that would follow.
selftest-cache-passthrough:
name: rust-cache obeys CARGO_INCREMENTAL
runs-on: ubuntu-latest
env:
CARGO_INCREMENTAL: "1"
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/rust-cache
with:
prefix: passthrough
- name: explicit CARGO_INCREMENTAL is preserved
run: test "$CARGO_INCREMENTAL" = "1"