Skip to content

Sealed family: msrv on the plain rust image, one lint-and-test script, seal.sh without caller boilerplate #169

Sealed family: msrv on the plain rust image, one lint-and-test script, seal.sh without caller boilerplate

Sealed family: msrv on the plain rust image, one lint-and-test script, seal.sh without caller boilerplate #169

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@v7
- 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
# lint-and-test-docker runs this same script inside its container with
# LOCKED=true and CLIPPY_ARGS=--all-targets; a job log cannot be grepped
# from inside the job, so pin the argv here with a recording cargo shim,
# for the sealed parameter set and for the native action's defaults.
- name: lint-and-test script builds the sealed clippy/test argv
working-directory: fixtures/sample-crate
run: |
shim="$RUNNER_TEMP/cargo-shim"
mkdir -p "$shim"
cat > "$shim/cargo" <<'SHIM'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$CARGO_ARGV"
SHIM
chmod +x "$shim/cargo"
script="$GITHUB_WORKSPACE/.github/actions/lint-and-test/lint-and-test.sh"
export CARGO_ARGV="$RUNNER_TEMP/cargo-argv"
: > "$CARGO_ARGV"
PATH="$shim:$PATH" FMT=true CLIPPY=true TEST=true CLIPPY_ARGS=--all-targets \
LOCKED=true OFFLINE=false FEATURES="" bash "$script"
printf '%s\n' 'fmt --all -- --check' \
'clippy --workspace --all-targets --locked -- -D warnings' \
'test --workspace --locked' | diff - "$CARGO_ARGV"
echo "✓ sealed parameter set: --all-targets and --locked on clippy, --locked on test"
: > "$CARGO_ARGV"
PATH="$shim:$PATH" FMT=true CLIPPY=true TEST=true CLIPPY_ARGS=--all-targets \
LOCKED=true OFFLINE=true FEATURES=--all-features bash "$script"
printf '%s\n' 'fmt --all -- --check' \
'clippy --workspace --all-targets --offline --locked --all-features -- -D warnings' \
'test --workspace --offline --locked --all-features' | diff - "$CARGO_ARGV"
echo "✓ OFFLINE=true adds --offline in front of --locked"
: > "$CARGO_ARGV"
PATH="$shim:$PATH" FMT=true CLIPPY=true TEST=true CLIPPY_ARGS=--all-targets \
FEATURES="" TEST_ARGS="" bash "$script"
printf '%s\n' 'fmt --all -- --check' \
'clippy --workspace --all-targets -- -D warnings' \
'test --workspace' | diff - "$CARGO_ARGV"
echo "✓ native defaults: no --locked, no --offline"
# The same toggles against the real toolchain: --locked accepts the fixture's
# committed Cargo.lock, and LOCKED=false drops the flag again.
- name: lint-and-test script runs clippy --all-targets --locked natively
working-directory: fixtures/sample-crate
env:
FMT: "false"
CLIPPY: "true"
TEST: "false"
CLIPPY_ARGS: --all-targets
OFFLINE: "false"
run: |
LOCKED=true bash "$GITHUB_WORKSPACE/.github/actions/lint-and-test/lint-and-test.sh"
LOCKED=false bash "$GITHUB_WORKSPACE/.github/actions/lint-and-test/lint-and-test.sh"
# cargo-out-dir: resolve the fixture build script's OUT_DIR.
# Dependencies' build scripts emit the same cargo message, so the
# assertion pins the PACKAGE filter: the resolved path must be the
# fixture's own build dir. (Deliberately no content assert, so that a
# restored target cache legitimately replays an earlier build-script
# execution.)
- name: cargo-out-dir (fixture)
id: fixture_out_dir
uses: ./.github/actions/cargo-out-dir
with:
working-directory: fixtures/sample-crate
package: cicd-rust-selftest-fixture
- name: cargo-out-dir resolved the fixture's build dir
run: |
dir="${{ steps.fixture_out_dir.outputs.out-dir }}"
test -d "$dir" || { echo "::error::out-dir is not a directory: $dir"; exit 1; }
case "$dir" in
*/cicd-rust-selftest-fixture-*/out) echo "✓ resolved the fixture's OUT_DIR" ;;
*) echo "::error::resolved a foreign build dir: $dir"; exit 1 ;;
esac
# Negative control: an unknown package must fail loudly instead of
# emitting an empty path for a later step to copy from.
- name: cargo-out-dir rejects an unknown package (negative control)
id: bogus_out_dir
continue-on-error: true
uses: ./.github/actions/cargo-out-dir
with:
working-directory: fixtures/sample-crate
package: no-such-package
- name: assert the unknown package failed
run: |
if [ "${{ steps.bogus_out_dir.outcome }}" != "failure" ]; then
echo "::error::unknown package did not fail"; exit 1
fi
echo "✓ unknown package rejected"
- 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 save half of rust-cache: prune target/ to dependency artifacts,
# upload under the restore's key. Runs as (nearly) the last step, the
# position consumers use; a fresh key exercises prune + upload, an
# exact-hit re-run skips both (the stored entry is already pruned).
- name: rust-cache-save (fixture; prune + upload)
uses: ./.github/actions/rust-cache-save
with:
working-directory: fixtures/sample-crate
- name: prune kept the dependency and dropped the member
if: env.RUST_CI_TARGET_HIT != 'true'
run: |
deps=fixtures/sample-crate/target/debug/deps
compgen -G "$deps/liblibc-*" >/dev/null || {
echo "::error::dependency artifacts missing after prune"; exit 1; }
if compgen -G "$deps/cicd_rust_selftest_fixture-*" >/dev/null; then
echo "::error::workspace member artifacts survived the prune"; exit 1
fi
echo "✓ prune kept libc, dropped the member"
# save: false is the pull-request mode: the step must no-op.
- name: rust-cache-save honours save=false (no-op control)
uses: ./.github/actions/rust-cache-save
with:
save: "false"
working-directory: fixtures/sample-crate
# The changelog action, both modes, against the fixture crate: the check gate
# (bump present, bump missing, breaking without more than a patch bump) and
# the cut (heading + compare link rewritten, the version exported, a second
# cut refused once nothing is unreleased).
selftest-changelog:
name: exercise the changelog action
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# cargo metadata resolves the crate version; the runner's default toolchain suffices.
- name: install-toolchain
uses: ./.github/actions/install-toolchain
- name: check passes with a bumped version
uses: ./.github/actions/changelog
with:
mode: check
working-directory: fixtures/sample-crate
baseline-version: "0.0.9"
- name: check fails without a bump (negative control)
id: nobump
continue-on-error: true
uses: ./.github/actions/changelog
with:
mode: check
working-directory: fixtures/sample-crate
baseline-version: "0.1.0"
- name: stage a breaking entry on a patch bump
run: |
rm -rf "$RUNNER_TEMP/breaking"
rsync -a --exclude='target*' fixtures/sample-crate/ "$RUNNER_TEMP/breaking/"
sed -i 's/^version = "0.1.0"/version = "0.1.1"/' "$RUNNER_TEMP/breaking/Cargo.toml"
awk '{print} /^## \[Unreleased\]/{print ""; print "- **Breaking:** a sample breaking entry."}' \
"$RUNNER_TEMP/breaking/CHANGELOG.md" > "$RUNNER_TEMP/breaking/CHANGELOG.md.new"
mv "$RUNNER_TEMP/breaking/CHANGELOG.md.new" "$RUNNER_TEMP/breaking/CHANGELOG.md"
- name: check refuses a breaking entry on a patch bump (negative control)
id: breaking_patch
continue-on-error: true
uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/breaking
baseline-version: "0.1.0"
- name: check accepts a breaking entry with a minor bump
run: sed -i 's/^version = "0.1.1"/version = "0.2.0"/' "$RUNNER_TEMP/breaking/Cargo.toml"
- uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/breaking
baseline-version: "0.1.0"
- name: stage a copy for the cut
run: |
rm -rf "$RUNNER_TEMP/cut"
rsync -a --exclude='target*' fixtures/sample-crate/ "$RUNNER_TEMP/cut/"
- name: cut releases the [Unreleased] section
uses: ./.github/actions/changelog
with:
mode: cut
working-directory: ${{ runner.temp }}/cut
date: "2026-02-02"
- name: the cut rewrote the heading and link and exported the version
run: |
grep -q '^## \[0.1.0\] - 2026-02-02$' "$RUNNER_TEMP/cut/CHANGELOG.md"
grep -q '^\[0.1.0\]: https://example.invalid/sample/compare/v0.0.9\.\.\.v0.1.0$' \
"$RUNNER_TEMP/cut/CHANGELOG.md"
if grep -q 'Unreleased' "$RUNNER_TEMP/cut/CHANGELOG.md"; then
echo "::error::an [Unreleased] remnant survived the cut"; exit 1
fi
test "$CHANGELOG_VERSION" = "0.1.0"
echo "✓ cut rewrote the section, the link, and exported CHANGELOG_VERSION"
- name: a second cut is refused (negative control)
id: cut_again
continue-on-error: true
uses: ./.github/actions/changelog
with:
mode: cut
working-directory: ${{ runner.temp }}/cut
# Release-candidate semantics: SemVer precedence (an rc orders below its
# final, unlike `sort -V`), the feature-resets-rc rule, and rc-aware
# baselines from tags.
- name: stage a release-candidate crate
run: |
rm -rf "$RUNNER_TEMP/rc"
rsync -a --exclude='target*' fixtures/sample-crate/ "$RUNNER_TEMP/rc/"
sed -i 's/^version = "0.1.0"/version = "1.0.0-rc1"/' "$RUNNER_TEMP/rc/Cargo.toml"
- name: check refuses a release candidate below its final (negative control)
id: rc_below_final
continue-on-error: true
uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/rc
baseline-version: "1.0.0"
- name: check refuses feature content on a release candidate (negative control)
id: rc_feature
continue-on-error: true
uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/rc
baseline-version: "0.9.9"
- name: a release candidate iterates on fixes
run: |
printf '# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- A candidate stabilization fix.\n\n## [0.9.9] - 2026-01-01\n' \
> "$RUNNER_TEMP/rc/CHANGELOG.md"
- uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/rc
baseline-version: "0.9.9"
- name: "stage tags: the baseline scan must rank the final over its rc"
run: |
git -C "$RUNNER_TEMP/rc" init -q
git -C "$RUNNER_TEMP/rc" add -A
git -C "$RUNNER_TEMP/rc" -c user.name=selftest -c user.email=selftest@invalid.example commit -qm fixture
git -C "$RUNNER_TEMP/rc" tag v1.0.0-rc1
git -C "$RUNNER_TEMP/rc" tag v1.0.0
sed -i 's/^version = "1.0.0-rc1"/version = "1.0.0"/' "$RUNNER_TEMP/rc/Cargo.toml"
- name: check refuses a version equal to the tag baseline final (negative control)
id: rc_stale_final
continue-on-error: true
uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/rc
- name: a stale candidate marker of a higher release does not block a stable version
run: |
git -C "$RUNNER_TEMP/rc" tag v2.0.0-rc1
sed -i 's/^version = "1.0.0"/version = "1.0.1"/' "$RUNNER_TEMP/rc/Cargo.toml"
- uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/rc
- name: a pre-release tag is a baseline of its own
run: |
git -C "$RUNNER_TEMP/rc" tag -d v1.0.0 v2.0.0-rc1
sed -i 's/^version = "1.0.1"/version = "1.0.0-rc2"/' "$RUNNER_TEMP/rc/Cargo.toml"
- uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/rc
- name: assert the negative controls failed
run: |
if [ "${{ steps.nobump.outcome }}" != "failure" ]; then
echo "::error::a missing bump was not refused"; exit 1
fi
if [ "${{ steps.breaking_patch.outcome }}" != "failure" ]; then
echo "::error::a breaking entry on a patch bump was not refused"; exit 1
fi
if [ "${{ steps.cut_again.outcome }}" != "failure" ]; then
echo "::error::a second cut was not refused"; exit 1
fi
if [ "${{ steps.rc_below_final.outcome }}" != "failure" ]; then
echo "::error::a release candidate was not refused below its final"; exit 1
fi
if [ "${{ steps.rc_feature.outcome }}" != "failure" ]; then
echo "::error::feature content on a release candidate was not refused"; exit 1
fi
if [ "${{ steps.rc_stale_final.outcome }}" != "failure" ]; then
echo "::error::a version equal to the tag baseline was not refused"; exit 1
fi
echo "✓ all changelog negative controls refused"
# changelog `notes` mode renders a released section as plaintext (awk extract +
# a mechanical de-Markdown). Stage a Markdown-rich section and assert the strip.
selftest-changelog-notes:
name: exercise the changelog notes mode
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: stage a changelog with a Markdown-rich section
run: |
set -euo pipefail
mkdir -p nt
{
echo '# Changelog'
echo
echo '## [1.2.3] - 2026-01-01'
echo
# shellcheck disable=SC2016 # the single quotes are the point
echo 'A release with `code`, **bold**, an `@import` token, and a provider-* glob.'
echo
echo '### Added'
echo
# shellcheck disable=SC2016 # the single quotes are the point
echo '- The **thing** with `session_auth` support.'
echo
echo '[1.2.3]: https://example.invalid/x/releases/tag/v1.2.3'
} > nt/CHANGELOG.md
- name: render the notes
uses: ./.github/actions/changelog
with:
mode: notes
version: "1.2.3"
title: "v1.2.3"
working-directory: nt
- name: assert Markdown stripped, structure and identifiers kept
run: |
set -euo pipefail
out=nt/release-notes.md
test -f "$out"
[ "$(head -1 "$out")" = "v1.2.3" ] || { echo "::error::subject line is not the title (v1.2.3)"; exit 1; }
if grep -q '`' "$out"; then echo "::error::backticks survived"; exit 1; fi
if grep -q '\*\*' "$out"; then echo "::error::bold markers survived"; exit 1; fi
if grep -q 'example.invalid' "$out"; then echo "::error::reference-link definition survived"; exit 1; fi
grep -qx 'Added:' "$out" || { echo "::error::### Added not converted"; exit 1; }
grep -q 'provider-\* glob' "$out" || { echo "::error::single * dropped"; exit 1; }
grep -q 'session_auth' "$out" || { echo "::error::identifier dropped"; exit 1; }
grep -q '@import' "$out" || { echo "::error::@import token dropped"; exit 1; }
echo "✓ changelog notes rendered plaintext"
- name: render the notes as Markdown (release body)
uses: ./.github/actions/changelog
with:
mode: notes
format: markdown
version: "1.2.3"
title: "v1.2.3"
out: release-body.md
working-directory: nt
- name: assert Markdown kept and the title line omitted
run: |
set -euo pipefail
out=nt/release-body.md
test -f "$out"
if [ "$(head -1 "$out")" = "v1.2.3" ]; then echo "::error::markdown body led with the title line"; exit 1; fi
# shellcheck disable=SC2016 # the single quotes are the point
grep -q '`@import`' "$out" || { echo "::error::code span dropped: @tokens would autolink as @mentions"; exit 1; }
# shellcheck disable=SC2016 # the single quotes are the point
grep -q '`code`' "$out" || { echo "::error::inline code stripped"; exit 1; }
grep -q '\*\*bold\*\*' "$out" || { echo "::error::bold markers stripped"; exit 1; }
grep -qx '### Added' "$out" || { echo "::error::### heading converted"; exit 1; }
echo "✓ changelog notes rendered Markdown (code spans kept)"
- name: notes refuses a version absent from the changelog (negative control)
id: notes_absent
continue-on-error: true
uses: ./.github/actions/changelog
with:
mode: notes
version: "9.9.9"
working-directory: nt
- name: assert the absent-version render failed
run: |
set -euo pipefail
[ "${{ steps.notes_absent.outcome }}" = "failure" ] || { echo "::error::notes did not refuse an absent version"; exit 1; }
echo "✓ notes refused an absent version"
selftest-cut-release:
name: exercise the cut-release action
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# cargo metadata resolves the crate version; the runner's default toolchain suffices.
- name: install-toolchain
uses: ./.github/actions/install-toolchain
- name: stage a crate with a scratch origin
run: |
rm -rf "$RUNNER_TEMP/cut-release" "$RUNNER_TEMP/cut-release-origin.git"
git init -q --bare "$RUNNER_TEMP/cut-release-origin.git"
rsync -a --exclude='target*' fixtures/sample-crate/ "$RUNNER_TEMP/cut-release/"
git -C "$RUNNER_TEMP/cut-release" init -q
git -C "$RUNNER_TEMP/cut-release" remote add origin "$RUNNER_TEMP/cut-release-origin.git"
git -C "$RUNNER_TEMP/cut-release" add -A
git -C "$RUNNER_TEMP/cut-release" -c user.name=selftest -c user.email=selftest@invalid.example commit -qm fixture
- name: dry run derives the version and branch and cuts the changelog
id: dry
uses: ./.github/actions/cut-release
with:
working-directory: ${{ runner.temp }}/cut-release
date: "2026-02-02"
dry-run: "true"
- name: the dry run produced outputs and touched no remote
run: |
test "${{ steps.dry.outputs.version }}" = "0.1.0"
test "${{ steps.dry.outputs.branch }}" = "release/v0.1.0"
grep -q '^## \[0.1.0\] - 2026-02-02$' "$RUNNER_TEMP/cut-release/CHANGELOG.md"
test -z "$(git -C "$RUNNER_TEMP/cut-release" ls-remote origin | head -1)"
echo "✓ dry run derived the version and branch, cut the tree, pushed nothing"
# The citation file takes the same version and the same date as the
# changelog section. cff-version is the reason the stamp matches keys
# whole-line instead of substituting the outgoing version string.
- name: the dry run stamped the citation file
run: |
cff="$RUNNER_TEMP/cut-release/CITATION.cff"
grep -qxF 'version: 0.1.0' "$cff"
grep -qxF 'date-released: 2026-02-02' "$cff"
grep -qxF 'cff-version: 1.2.0' "$cff"
echo "✓ citation stamped with the release version and the cut date, cff-version intact"
- name: reset the tree for the opt-out run
run: git -C "$RUNNER_TEMP/cut-release" checkout -q -- CHANGELOG.md CITATION.cff
- name: an empty citation input disables the stamp
uses: ./.github/actions/cut-release
with:
working-directory: ${{ runner.temp }}/cut-release
date: "2026-02-02"
citation: ""
dry-run: "true"
- name: the citation was left untouched
run: |
cff="$RUNNER_TEMP/cut-release/CITATION.cff"
grep -qxF 'version: 0.0.0' "$cff"
grep -qxF 'date-released: 2020-01-01' "$cff"
echo "✓ empty citation input left the file alone"
- name: stage an existing release branch
run: |
git -C "$RUNNER_TEMP/cut-release" checkout -q -- CHANGELOG.md CITATION.cff
git -C "$RUNNER_TEMP/cut-release" push -q origin HEAD:refs/heads/release/v0.1.0
- name: an existing release branch is refused (negative control)
id: branch_exists
continue-on-error: true
uses: ./.github/actions/cut-release
with:
working-directory: ${{ runner.temp }}/cut-release
dry-run: "true"
- name: assert the negative control failed before touching the tree
run: |
if [ "${{ steps.branch_exists.outcome }}" != "failure" ]; then
echo "::error::an existing release branch was not refused"; exit 1
fi
grep -q '^## \[Unreleased\]' "$RUNNER_TEMP/cut-release/CHANGELOG.md"
echo "✓ existing branch refused before the changelog was touched"
# The version ladder without Cargo.toml: the explicit input on cut-release
# and readiness, the changelog's newest released section as the fallback,
# and check's section/tag coherence. Staged in a crateless temp dir.
selftest-version-input:
name: exercise the no-crate version ladder
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: stage a crateless repository with a changelog
run: |
rm -rf "$RUNNER_TEMP/no-crate"
mkdir -p "$RUNNER_TEMP/no-crate"
cd "$RUNNER_TEMP/no-crate"
git init -q
{
echo '# Changelog'
echo
echo '## [Unreleased]'
echo
echo '### Added'
echo
echo '- A pending feature.'
echo
echo '## [1.1.0] - 2026-01-01'
echo
echo '### Added'
echo
echo '- Something released.'
echo
echo '[Unreleased]: https://example.invalid/x/compare/v1.1.0...HEAD'
echo '[1.1.0]: https://example.invalid/x/releases/tag/v1.1.0'
} > CHANGELOG.md
git add -A
git -c user.name=selftest -c user.email=selftest@invalid.example commit -qm fixture
git tag v1.1.0
- name: check without a version passes on a tagged newest section
uses: ./.github/actions/changelog
with:
mode: check
working-directory: ${{ runner.temp }}/no-crate
- name: cut-release dry run with the explicit version
id: dry
uses: ./.github/actions/cut-release
with:
version: "1.2.0"
date: "2026-02-02"
dry-run: "true"
working-directory: ${{ runner.temp }}/no-crate
- name: the dry run cut the named version
run: |
test "${{ steps.dry.outputs.version }}" = "1.2.0"
test "${{ steps.dry.outputs.branch }}" = "release/v1.2.0"
grep -q '^## \[1.2.0\] - 2026-02-02$' "$RUNNER_TEMP/no-crate/CHANGELOG.md"
echo "✓ the explicit version named the cut"
- name: readiness takes the explicit version
uses: ./.github/actions/check-release-readiness
with:
version: "1.2.0"
expected-version: "1.2.0"
working-directory: ${{ runner.temp }}/no-crate
- name: readiness falls back to the changelog's newest section
uses: ./.github/actions/check-release-readiness
with:
expected-version: "1.2.0"
working-directory: ${{ runner.temp }}/no-crate
- name: notes falls back to the newest section
uses: ./.github/actions/changelog
with:
mode: notes
title: v1.2.0
working-directory: ${{ runner.temp }}/no-crate
- name: the rendered notes carry the cut section
run: |
test "$(head -1 "$RUNNER_TEMP/no-crate/release-notes.md")" = "v1.2.0"
grep -q 'A pending feature' "$RUNNER_TEMP/no-crate/release-notes.md"
echo "✓ notes rendered the newest section without a version input"
- name: cut without a version is refused (negative control)
id: no_version
continue-on-error: true
uses: ./.github/actions/cut-release
with:
dry-run: "true"
working-directory: ${{ runner.temp }}/no-crate
- name: assert the negative control failed
run: |
test "${{ steps.no_version.outcome }}" = "failure"
echo "✓ a versionless non-crate cut is refused"
# The repository's own changelog is release material: dogfood the check.
changelog-check:
name: changelog check (this repository)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# The section/tag coherence derives from the tags.
fetch-depth: 0
- uses: ./.github/actions/changelog
with:
mode: check
selftest-release-helpers:
name: exercise the release guidance and the signed-tag gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# The action wiring writes the real step summary of this run.
- name: release-guidance renders into the step summary
uses: ./.github/actions/release-guidance
with:
version: "0.1.0"
marker-tag: "v0.1.0-rc1"
commit: "0000000000000000000000000000000000000000"
- name: the guidance carries the accept, reject, and next blocks
run: |
S="$RUNNER_TEMP/guidance.md"
GITHUB_ACTION_PATH=.github/actions/release-guidance \
GITHUB_STEP_SUMMARY="$S" INPUT_VERSION=1.2.3 INPUT_MARKER_TAG=v1.2.3-rc2 \
INPUT_COMMIT=0123456789abcdef0123456789abcdef01234567 \
INPUT_TAG_SCRIPT=scripts/tag-release.sh INPUT_DRAFT_URL=https://example.invalid/v1.2.3 \
bash .github/actions/release-guidance/release-guidance.sh >/dev/null
test "$(grep -c '^### ' "$S")" = "3"
grep -q 'scripts/tag-release.sh 0123456789abcdef0123456789abcdef01234567 -s' "$S"
grep -q '^git push origin v1.2.3$' "$S"
# shellcheck disable=SC2016 # the single quotes are the point
grep -q 'never `git push --tags`' "$S"
grep -q 'the draft release](https://example.invalid/v1.2.3)' "$S"
GITHUB_ACTION_PATH=.github/actions/release-guidance \
GITHUB_STEP_SUMMARY="$S.plain" INPUT_VERSION=1.2.3 INPUT_MARKER_TAG=v1.2.3-rc1 \
INPUT_COMMIT=abc0123 INPUT_TAG_SCRIPT='' INPUT_DRAFT_URL='' \
bash .github/actions/release-guidance/release-guidance.sh >/dev/null
grep -qF "git tag -s -F <(git tag -l --format='%(contents)' v1.2.3-rc1) v1.2.3 abc0123" "$S.plain"
echo "✓ guidance blocks, commands, and the tag-script variant render"
- name: require-signed-tag refuses a non-tag ref (negative control)
id: no_tag
continue-on-error: true
uses: ./.github/actions/require-signed-tag
# A non-tag ref is a wiring mistake, not a signature preference: warn-only
# does not soften it.
- name: warn-only still refuses a non-tag ref (negative control)
id: no_tag_warn
continue-on-error: true
uses: ./.github/actions/require-signed-tag
with:
warn-only: "true"
- name: assert the negative controls failed
run: |
if [ "${{ steps.no_tag.outcome }}" != "failure" ]; then
echo "::error::a non-tag ref was not refused"; exit 1
fi
if [ "${{ steps.no_tag_warn.outcome }}" != "failure" ]; then
echo "::error::warn-only softened a non-tag ref"; exit 1
fi
echo "✓ the signed-tag gate refused the non-tag refs"
# 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@v7
- name: build-image (local, cached, with a cross target)
uses: ./.github/actions/build-image
with:
tag: rust-ci:selftest
cache: "true"
targets: wasm32-unknown-unknown
# build-image `targets`: the requested cross target must land in the image, so
# the sealed Docker actions can later cargo-check against it.
- name: build-image added the requested target (targets input)
run: |
if docker run --rm rust-ci:selftest rustup target list --installed | grep -qx wasm32-unknown-unknown; then
echo "✓ wasm32-unknown-unknown present in the image"
else
echo "::error::build-image did not add the requested target"; exit 1
fi
# 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; no out-dir-package also proves the resolve step skips cleanly).
- name: cargo-docker (sealed build)
uses: ./.github/actions/cargo-docker
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
args: "build --locked"
# Arguments after `--` belong to the test binary; --offline must not land
# among them.
- name: cargo-docker (sealed test with binary arguments)
uses: ./.github/actions/cargo-docker
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
args: "test --locked -- --nocapture"
# cargo-docker resolves a build script's OUT_DIR as a HOST path (the sealed
# replay + the /work/target → target-dir translation).
- name: cargo-docker (sealed build + out-dir)
id: docker_out_dir
uses: ./.github/actions/cargo-docker
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
args: "build --locked"
out-dir-package: cicd-rust-selftest-fixture
out-dir-args: "--locked"
- name: cargo-docker translated the fixture's OUT_DIR to the host
run: |
dir="${{ steps.docker_out_dir.outputs.out-dir }}"
test -d "$dir" || { echo "::error::out-dir is not a host directory: $dir"; exit 1; }
case "$dir" in
"$PWD"/fixtures/sample-crate/target/*/build/cicd-rust-selftest-fixture-*/out)
echo "✓ host path under the mounted target dir" ;;
*) echo "::error::not a translated host path: $dir"; exit 1 ;;
esac
- name: cargo-docker rejects an unknown out-dir-package (negative control)
id: docker_bogus_out_dir
continue-on-error: true
uses: ./.github/actions/cargo-docker
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
args: "build --locked"
out-dir-package: no-such-package
- name: assert the unknown out-dir-package failed
run: |
if [ "${{ steps.docker_bogus_out_dir.outcome }}" != "failure" ]; then
echo "::error::unknown out-dir-package did not fail"; exit 1
fi
echo "✓ unknown out-dir-package rejected"
- 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"
# Absolute, outside the crate: a second build tree inside it would end
# up in the .crate the publish-dry-run leg below packages.
cargo-cache: ${{ runner.temp }}/cargo-cache-online
target-dir: ${{ runner.temp }}/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: "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"
# The msrv action pulls rust:<msrv> itself, so its legs need no image build and
# run beside the Docker legs instead of after them.
selftest-msrv:
name: exercise the msrv action
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# msrv: pull rust:<declared rust-version> and cargo check there, sealed.
# Exercises the Cargo.toml extraction, the image pull, and the sealed check.
# locked: true (the default) takes the committed-lockfile path: the fixture pins one.
- name: msrv (locked true, rust 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).
# locked: true, so the lockfile precondition passes and the version check is what fails.
- 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"
locked: "true"
- 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"
# locked: false (opt-in): 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
locked: "false"
# 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
locked: "false"
- 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"
# Regression: a consumer's build.rs may read git metadata (git describe, the commit
# hash), so the disposable copy must preserve it, here in the plain .git DIRECTORY
# form. The fixture's build.rs asserts `git rev-parse HEAD` succeeds whenever
# FIXTURE_REQUIRE_GIT is set (forwarded into the container via the literal env input).
- name: stage a git-repo fixture with no Cargo.lock (.git directory)
run: |
rm -rf "$RUNNER_TEMP/fixture-gitdir"
mkdir -p "$RUNNER_TEMP/fixture-gitdir"
rsync -a --exclude='target*' --exclude='Cargo.lock' fixtures/sample-crate/ "$RUNNER_TEMP/fixture-gitdir/"
git -C "$RUNNER_TEMP/fixture-gitdir" init -q -b main
# gc/maintenance off: a detached repack racing the action's .git copy is
# the action's retry path, not this test's subject.
git -C "$RUNNER_TEMP/fixture-gitdir" config gc.auto 0
git -C "$RUNNER_TEMP/fixture-gitdir" config maintenance.auto false
git -C "$RUNNER_TEMP/fixture-gitdir" -c user.name=selftest -c user.email=selftest@invalid add -A
git -C "$RUNNER_TEMP/fixture-gitdir" -c user.name=selftest -c user.email=selftest@invalid commit -q -m fixture
test -d "$RUNNER_TEMP/fixture-gitdir/.git"
- name: msrv (locked false) preserves git metadata in the copy (.git directory)
uses: ./.github/actions/msrv
with:
working-directory: ${{ runner.temp }}/fixture-gitdir
package: cicd-rust-selftest-fixture
locked: "false"
env: |
FIXTURE_REQUIRE_GIT=1
# The same guarantee for the .git FILE form: a linked worktree of that repo (the
# form submodules use as well).
- name: stage a linked-worktree fixture (.git file)
run: |
rm -rf "$RUNNER_TEMP/fixture-gitfile"
git -C "$RUNNER_TEMP/fixture-gitdir" worktree add -q -b wt "$RUNNER_TEMP/fixture-gitfile"
test -f "$RUNNER_TEMP/fixture-gitfile/.git"
test ! -e "$RUNNER_TEMP/fixture-gitfile/Cargo.lock"
- name: msrv (locked false) preserves git metadata in the copy (.git file / worktree)
uses: ./.github/actions/msrv
with:
working-directory: ${{ runner.temp }}/fixture-gitfile
package: cicd-rust-selftest-fixture
locked: "false"
env: |
FIXTURE_REQUIRE_GIT=1
# 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@v7
- 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@v7
- uses: ./.github/actions/rust-cache
with:
prefix: passthrough
- name: explicit CARGO_INCREMENTAL is preserved
run: test "$CARGO_INCREMENTAL" = "1"
# rust-cache must resolve the cargo home from CARGO_HOME (container images bake the
# toolchain outside $HOME/.cargo): seed a sentinel under a custom CARGO_HOME, let the
# post step save it, and prove a second job restores it into the same custom location.
selftest-cache-cargo-home-save:
name: rust-cache honors CARGO_HOME (save)
runs-on: ubuntu-latest
env:
CARGO_HOME: /tmp/cargo-home
steps:
- uses: actions/checkout@v7
- name: seed a sentinel in the custom cargo home
run: |
mkdir -p "$CARGO_HOME/registry/index" "$CARGO_HOME/registry/cache" "$CARGO_HOME/git/db"
echo "${{ github.run_id }}" > "$CARGO_HOME/registry/cache/selftest-sentinel"
- uses: ./.github/actions/rust-cache
with:
prefix: cargohome-${{ github.run_id }}
selftest-cache-cargo-home-restore:
name: rust-cache honors CARGO_HOME (restore)
needs: selftest-cache-cargo-home-save
runs-on: ubuntu-latest
env:
CARGO_HOME: /tmp/cargo-home
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/rust-cache
with:
prefix: cargohome-${{ github.run_id }}
save: "false"
- name: sentinel came back into the custom cargo home
run: test "$(cat "$CARGO_HOME/registry/cache/selftest-sentinel")" = "${{ github.run_id }}"
# save:"false" must not create a cache entry: run restore-only under a unique prefix,
# then assert no entry with that prefix exists.
selftest-cache-no-save:
name: rust-cache save:false saves nothing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/rust-cache
with:
prefix: nosave-${{ github.run_id }}
save: "false"
selftest-cache-uploaded-nothing:
name: rust-cache save:false and local-target uploaded nothing
needs: [selftest-cache-no-save, selftest-cache-local-target]
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: no cache entry exists for the nosave prefix
run: |
COUNT=$(gh cache list --repo ${{ github.repository }} --limit 100 --json key \
--jq '[.[] | select(.key | startswith("nosave-${{ github.run_id }}-"))] | length')
test "$COUNT" -eq 0
- name: no target entry exists for the local-target prefix
run: |
COUNT=$(gh cache list --repo ${{ github.repository }} --limit 100 --json key \
--jq '[.[] | select(.key | startswith("localtgt-${{ github.run_id }}-Linux-target-"))] | length')
test "$COUNT" -eq 0 || { echo "::error::uploaded $COUNT target entries"; exit 1; }
echo "✓ nothing transferred"
# The property the whole mode rests on: a target dir outside the workspace
# survives the `git clean -ffdx` that actions/checkout runs.
selftest-cache-local-target:
name: rust-cache local-target survives git clean
runs-on: ubuntu-latest
env:
RUST_CI_LOCAL_TARGET: "1"
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/rust-cache
with:
prefix: localtgt-${{ github.run_id }}
cache-target: "true"
local-target: "auto"
lockfiles: fixtures/sample-crate/Cargo.lock
- name: auto fired, target sits outside the workspace, no key handed over
run: |
[ -n "${CARGO_TARGET_DIR:-}" ] || { echo "::error::local-target did not activate"; exit 1; }
[ -d "$CARGO_TARGET_DIR" ] || { echo "::error::$CARGO_TARGET_DIR missing"; exit 1; }
case "$CARGO_TARGET_DIR" in
"$GITHUB_WORKSPACE"/*) echo "::error::target dir is inside the workspace"; exit 1 ;;
esac
[ -z "${RUST_CI_TARGET_KEY:-}" ] || { echo "::error::key handed over: $RUST_CI_TARGET_KEY"; exit 1; }
echo "✓ $CARGO_TARGET_DIR, outside the workspace, no key"
- name: git clean -ffdx does not reach it
run: |
echo "${{ github.run_id }}" > "$CARGO_TARGET_DIR/sentinel"
git clean -ffdx
test "$(cat "$CARGO_TARGET_DIR/sentinel")" = "${{ github.run_id }}" || {
echo "::error::checkout's clean would delete the target dir"; exit 1; }
echo "✓ sentinel survived git clean -ffdx"
- uses: ./.github/actions/rust-cache-save
with:
working-directory: fixtures/sample-crate
- name: rust-cache-save left it alone
run: |
test "$(cat "$CARGO_TARGET_DIR/sentinel")" = "${{ github.run_id }}" || {
echo "::error::rust-cache-save pruned a tree it does not own"; exit 1; }
echo "✓ nothing pruned"
# A host's job-started hook hands its variables over as job environment,
# and the runner passes the job environment into every step of a
# container: job. This job proves that path on a hosted runner: the
# variable sits at job level, the action sees it inside the container, the
# wrapper lands under the bind-mounted work tree, and the round-trip hits.
selftest-sccache-container:
name: sccache inside a container job keyed on the job env
runs-on: ubuntu-latest
container: rust:1-bookworm
env:
SCCACHE_DIR: /tmp/sccache-store
steps:
- uses: actions/checkout@v7
- run: apt-get update -qq && apt-get install -y -qq jq >/dev/null
- uses: ./.github/actions/sccache
id: sccache
- name: auto fired inside the container, wrapper under the mounted work tree
run: |
[ "${{ steps.sccache.outputs.active }}" = "true" ] || { echo "::error::auto did not activate"; exit 1; }
[ -x "${RUSTC_WRAPPER:-/nonexistent}" ] || { echo "::error::RUSTC_WRAPPER is not executable: ${RUSTC_WRAPPER:-unset}"; exit 1; }
case "$RUSTC_WRAPPER" in /__w/_temp/*) ;; *) echo "::error::RUSTC_WRAPPER is not under the mounted work tree: $RUSTC_WRAPPER"; exit 1 ;; esac
echo "✓ active, $RUSTC_WRAPPER wraps rustc inside the container"
- name: first build stores, second build hits
working-directory: fixtures/sample-crate
run: |
cargo build --locked --quiet
[ -d "$SCCACHE_DIR" ] || { echo "::error::sccache did not use the offered SCCACHE_DIR"; exit 1; }
rm -rf target
cargo build --locked --quiet
hits=$("$RUSTC_WRAPPER" --show-stats --stats-format=json | jq '[.stats.cache_hits.counts[]?] | add // 0')
[ "$hits" -gt 0 ] || { echo "::error::no cache hits after a repeat build"; "$RUSTC_WRAPPER" --show-stats; exit 1; }
echo "✓ $hits hit(s) through the backend inside the container"
# Without RUST_CI_LOCAL_TARGET, SCCACHE_* or a mirror URL in the job env,
# every "auto" must stay off, or a hosted runner silently changes behaviour:
# rust-cache stops caching, sccache gains a wrapper nobody reads, cargo gets
# a source replacement. One job, a job-scoped CARGO_HOME so the runner's
# real cargo home stays untouched.
selftest-runner-services-off:
name: runner services stay off without a job-env offer
runs-on: ubuntu-latest
env:
CARGO_HOME: /tmp/selftest-off
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/rust-cache
with:
prefix: localoff-${{ github.run_id }}
cache-target: "true"
local-target: "auto"
save: "false"
lockfiles: fixtures/sample-crate/Cargo.lock
- name: rust-cache took the normal path
run: |
[ -z "${CARGO_TARGET_DIR:-}" ] || { echo "::error::CARGO_TARGET_DIR was set: $CARGO_TARGET_DIR"; exit 1; }
[ -n "${RUST_CI_TARGET_KEY:-}" ] || { echo "::error::no key handed over"; exit 1; }
echo "✓ local-target auto stayed off; key handed over as usual"
- name: local-target true without cache-target refuses
id: refuse
continue-on-error: true
uses: ./.github/actions/rust-cache
with:
prefix: localrefuse-${{ github.run_id }}
local-target: "true"
save: "false"
lockfiles: fixtures/sample-crate/Cargo.lock
- name: the refusal was loud
run: |
[ "${{ steps.refuse.outcome }}" = "failure" ] || { echo "::error::local-target without cache-target was accepted"; exit 1; }
echo "✓ refused"
- uses: ./.github/actions/sccache
id: sccache
- name: sccache wired nothing
run: |
[ "${{ steps.sccache.outputs.active }}" = "false" ] || { echo "::error::sccache activated without a backend"; exit 1; }
[ -z "${RUSTC_WRAPPER:-}" ] || { echo "::error::RUSTC_WRAPPER was set: $RUSTC_WRAPPER"; exit 1; }
echo "✓ sccache auto stayed off"
- uses: ./.github/actions/sccache-stats
- name: stats no-op without sccache
run: echo "✓ sccache-stats survived an inactive job"
- uses: ./.github/actions/crates-mirror
id: mirror
- name: crates-mirror wrote nothing
run: |
[ "${{ steps.mirror.outputs.active }}" = "false" ] || { echo "::error::crates-mirror activated without a URL"; exit 1; }
[ ! -e "$CARGO_HOME/config.toml" ] || { echo "::error::config.toml appeared: $(cat "$CARGO_HOME/config.toml")"; exit 1; }
echo "✓ crates-mirror auto stayed off"
# sccache: the job offers a local-disk backend (SCCACHE_DIR) through
# $GITHUB_ENV, the channel a host's job-started hook uses, which exercises
# auto activation, the pinned install and the wrapper export plus a real
# cache round-trip: the second build's rlib compiles must hit what the
# first build stored.
selftest-sccache:
name: sccache round-trip through a job-env backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: offer a backend the way a job-started hook would
run: echo "SCCACHE_DIR=$RUNNER_TEMP/sccache-store" >> "$GITHUB_ENV"
- uses: ./.github/actions/sccache
id: sccache
- name: auto fired and the wrapper is an absolute, executable path
run: |
[ "${{ steps.sccache.outputs.active }}" = "true" ] || { echo "::error::auto did not activate"; exit 1; }
[ -x "${RUSTC_WRAPPER:-/nonexistent}" ] || { echo "::error::RUSTC_WRAPPER is not executable: ${RUSTC_WRAPPER:-unset}"; exit 1; }
case "$RUSTC_WRAPPER" in /*) ;; *) echo "::error::RUSTC_WRAPPER is not absolute"; exit 1 ;; esac
echo "✓ active, $RUSTC_WRAPPER wraps rustc"
- name: first build stores, second build hits
working-directory: fixtures/sample-crate
run: |
cargo build --locked --quiet
[ -d "$SCCACHE_DIR" ] || { echo "::error::sccache did not use the offered SCCACHE_DIR"; exit 1; }
rm -rf target
cargo build --locked --quiet
hits=$("$RUSTC_WRAPPER" --show-stats --stats-format=json | jq '[.stats.cache_hits.counts[]?] | add // 0')
[ "$hits" -gt 0 ] || { echo "::error::no cache hits after a repeat build"; "$RUSTC_WRAPPER" --show-stats; exit 1; }
echo "✓ $hits hit(s) through the backend"
- uses: ./.github/actions/sccache-stats
- name: the stats landed as cache.sccache facts
run: |
notes="${RUST_CI_TIMING_DIR:-$RUNNER_TEMP/rust-ci-timing}/notes.tsv"
grep -q '^cache\.sccache' "$notes" || { echo "::error::no cache.sccache fact in $notes"; exit 1; }
echo "✓ $(grep -c '^cache\.sccache' "$notes") fact(s) recorded"
# gha: GitHub's cache service as the backend, the explicit hosted-runner
# choice. The round-trip is real: the rebuild's hits come back from the
# cache service, write-then-read within one job.
selftest-sccache-gha:
name: sccache round-trip through the GitHub cache service
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/sccache
id: sccache
with:
mode: gha
- name: the gha backend is wired
run: |
[ "${{ steps.sccache.outputs.active }}" = "true" ] || { echo "::error::gha mode did not activate"; exit 1; }
[ "${SCCACHE_GHA_ENABLED:-}" = "true" ] || { echo "::error::SCCACHE_GHA_ENABLED not exported"; exit 1; }
[ -n "${ACTIONS_RESULTS_URL:-}" ] || { echo "::error::cache-service runtime not exported"; exit 1; }
echo "✓ gha backend wired"
# A warm scope (a branch cut after a main run) serves hits on the first
# build instead of writing; either proves the wire.
- name: first build stores or hits, second build hits
working-directory: fixtures/sample-crate
run: |
cargo build --locked --quiet
stats=$("$RUSTC_WRAPPER" --show-stats --stats-format=json)
writes=$(jq '.stats.cache_writes // 0' <<<"$stats")
first=$(jq '[.stats.cache_hits.counts[]?] | add // 0' <<<"$stats")
[ "$writes" -gt 0 ] || [ "$first" -gt 0 ] || { echo "::error::the cache service neither stored nor served anything"; "$RUSTC_WRAPPER" --show-stats; exit 1; }
rm -rf target
cargo build --locked --quiet
hits=$("$RUSTC_WRAPPER" --show-stats --stats-format=json | jq '[.stats.cache_hits.counts[]?] | add // 0')
[ "$hits" -gt 0 ] || { echo "::error::no cache hits after a repeat build"; "$RUSTC_WRAPPER" --show-stats; exit 1; }
echo "✓ $writes write(s), $hits hit(s) through the cache service"
# crates-mirror: a REAL sparse mirror, ten lines of setup: the upstream
# config.json plus the one index file the fixture needs, served locally.
# Pointing the replacement at index.crates.io itself would not do: cargo
# rejects a replacement source whose URL equals the builtin crates-io
# source ("defined multiple times"). Downloads still come from
# static.crates.io (the mirror's config.json says so), which is exactly a
# pull-through's shape. CARGO_HOME is job-scoped so the runner's real
# cargo home stays untouched. The URL arrives as job env, the channel a
# host's job-started hook uses; the idempotence run passes it as the input.
selftest-crates-mirror:
name: crates-mirror routes crates-io through the offered URL
runs-on: ubuntu-latest
env:
CARGO_HOME: /tmp/selftest-cargo-mirror
RUST_CI_CRATES_MIRROR: sparse+http://127.0.0.1:8399/
steps:
- uses: actions/checkout@v7
- name: serve a minimal sparse mirror
run: |
mkdir -p /tmp/sparse-mirror/li/bc
curl -sSfL https://index.crates.io/config.json -o /tmp/sparse-mirror/config.json
curl -sSfL https://index.crates.io/li/bc/libc -o /tmp/sparse-mirror/li/bc/libc
(cd /tmp/sparse-mirror && python3 -m http.server 8399 &>/tmp/sparse-mirror.log &)
for _ in $(seq 1 20); do curl -sf http://127.0.0.1:8399/config.json >/dev/null && break; sleep 0.5; done
- uses: ./.github/actions/crates-mirror
id: mirror
- name: the replacement was written
run: |
[ "${{ steps.mirror.outputs.active }}" = "true" ] || { echo "::error::auto did not activate"; exit 1; }
grep -q '^\[source\.crates-io\]' "$CARGO_HOME/config.toml" || { echo "::error::no source replacement in $CARGO_HOME/config.toml"; exit 1; }
grep -qF 'registry = "sparse+http://127.0.0.1:8399/"' "$CARGO_HOME/config.toml" || { echo "::error::mirror URL missing"; exit 1; }
echo "✓ crates-io replaced"
- name: cargo resolves through the replacement
working-directory: fixtures/sample-crate
run: |
cargo fetch --locked
echo "✓ fetch through the replacement"
- name: a second run through the url input is idempotent
uses: ./.github/actions/crates-mirror
with:
url: sparse+http://127.0.0.1:8399/
- name: no duplicate block was appended
run: |
count=$(grep -c '^\[source\.crates-io\]' "$CARGO_HOME/config.toml")
[ "$count" -eq 1 ] || { echo "::error::$count crates-io blocks in config.toml"; exit 1; }
echo "✓ idempotent"
# cargo-publish: the dry run is the default and reads no credential; an upload
# without one refuses; already-published is decided before any credential is
# required. The upload path itself is exercised by real releases.
selftest-cargo-publish:
name: cargo-publish (dry run · refusal · duplicate)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Dry run (default)
id: dry
uses: ./.github/actions/cargo-publish
with:
working-directory: fixtures/sample-crate
- name: Assert nothing was published
env:
PUBLISHED: ${{ steps.dry.outputs.published }}
VERSION: ${{ steps.dry.outputs.version }}
run: |
[ "$PUBLISHED" = "false" ] || { echo "::error::dry run reported published=$PUBLISHED"; exit 1; }
[ -n "$VERSION" ] || { echo "::error::no version output"; exit 1; }
echo "dry run reported published=false for $VERSION"
# A release outside tag-pattern is a skip, not an upload: the policy that
# keeps prereleases and bare majors off the registry.
- name: Publish a version the pattern excludes
id: excluded
uses: ./.github/actions/cargo-publish
with:
working-directory: fixtures/sample-crate
publish: "true"
tag-pattern: "^v9\\."
- name: Assert the pattern skipped it
env:
PUBLISHED: ${{ steps.excluded.outputs.published }}
run: |
[ "$PUBLISHED" = "false" ] || { echo "::error::an excluded version reported published=$PUBLISHED"; exit 1; }
echo "a version outside tag-pattern skipped, with no credential in play"
- name: Upload without a credential
id: refuse
continue-on-error: true
uses: ./.github/actions/cargo-publish
with:
working-directory: fixtures/sample-crate
publish: "true"
- name: Assert it refused
env:
OUTCOME: ${{ steps.refuse.outcome }}
run: |
[ "$OUTCOME" = "failure" ] || { echo "::error::publish: true ran without a credential (outcome: $OUTCOME)"; exit 1; }
echo "correctly refused to upload without a credential"
# Already-published precedes the credential, so an allowed re-run of a
# published version skips with no token in play. The manifest names a
# version that is on crates.io forever; nothing is built or uploaded.
- name: Fixture whose name and version are on crates.io
run: |
mkdir -p "$RUNNER_TEMP/published-crate/src"
printf '[package]\nname = "serde"\nversion = "1.0.0"\nedition = "2015"\n\n[workspace]\n' \
> "$RUNNER_TEMP/published-crate/Cargo.toml"
: > "$RUNNER_TEMP/published-crate/src/lib.rs"
- name: Already-published skips without a credential
id: duplicate
uses: ./.github/actions/cargo-publish
with:
working-directory: ${{ runner.temp }}/published-crate
publish: "true"
allow-already-published: "true"
- name: Assert the duplicate skipped
env:
PUBLISHED: ${{ steps.duplicate.outputs.published }}
ALREADY: ${{ steps.duplicate.outputs.already-published }}
run: |
[ "$PUBLISHED" = "false" ] || { echo "::error::a duplicate reported published=$PUBLISHED"; exit 1; }
[ "$ALREADY" = "true" ] || { echo "::error::already-published=$ALREADY for a published version"; exit 1; }
echo "an already-published version skipped, with no credential in play"
# retry-transient: the registry failing is worth another attempt, the build
# failing on its own terms is not.
selftest-retry-transient:
name: exercise the transient-retry helper
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: a registry hiccup retries, a build error does not
run: |
set -euo pipefail
# shellcheck source=/dev/null
source .github/actions/_lib/retry-transient.sh
export RETRY_DELAY=0 RETRY_ATTEMPTS=3
count="$(mktemp)"
bump() { n=$(( $(cat "$count") + 1 )); echo "$n" > "$count"; printf '%s' "$n"; }
flaky() {
if [ "$(bump)" -ge 3 ]; then echo "#5 DONE"; return 0; fi
echo 'failed to do request: Head "https://registry-1.docker.io/v2/library/rust/manifests/1.95": dial tcp 52.5.201.0:443: i/o timeout'
return 1
}
genuine() { bump >/dev/null; echo "Dockerfile:5 RUN rustup component add clippy: exit code 1"; return 7; }
cargo_flaky() {
if [ "$(bump)" -ge 2 ]; then echo "resolved"; return 0; fi
echo 'warning: spurious network error (1 tries remaining): [28] Timeout was reached (Failed to connect to index.crates.io port 443)'
return 101
}
echo 0 > "$count"
retry_transient flaky >/dev/null
[ "$(cat "$count")" = "3" ] || { echo "::error::a registry timeout was not retried"; exit 1; }
# A crates.io timeout (cargo/curl text), not only a Docker registry error, must retry.
echo 0 > "$count"
retry_transient cargo_flaky >/dev/null
[ "$(cat "$count")" = "2" ] || { echo "::error::a crates.io network timeout was not retried"; exit 1; }
echo 0 > "$count"
status=0
retry_transient genuine >/dev/null || status=$?
[ "$(cat "$count")" = "1" ] || { echo "::error::a genuine build failure was retried"; exit 1; }
[ "$status" = "7" ] || { echo "::error::a genuine build failure lost its exit status ($status)"; exit 1; }
# The helper reads failure off a pipeline, so it enforces pipefail
# itself: a caller without it must still be told the truth, and must
# get its own options back.
set +o pipefail
status=0
retry_transient genuine >/dev/null || status=$?
[ "$status" = "7" ] || { echo "::error::a failure was swallowed for a caller without pipefail ($status)"; exit 1; }
case "$(set +o)" in
*"set -o pipefail"*) echo "::error::pipefail leaked into the caller"; exit 1 ;;
esac
set -o pipefail
# A bound that is not a number must fail loudly rather than spin until
# the workflow timeout.
status=0
RETRY_ATTEMPTS=abc timeout 10 bash -c 'source .github/actions/_lib/retry-transient.sh
always() { echo "i/o timeout"; return 1; }
retry_transient always' >/dev/null 2>&1 || status=$?
[ "$status" = "2" ] || { echo "::error::a non-numeric RETRY_ATTEMPTS did not fail fast ($status)"; exit 1; }
echo "✓ registry timeouts retry; build errors fail once keeping their status; the contract enforces itself"
# route-git-token: the exported GIT_CONFIG_* entries resolve as git
# config in later steps, a second invocation appends behind the first
# instead of clobbering it, a non-GitHub host carries its own username
# convention, and host/path values that could not name a forge namespace
# are refused before anything reaches the environment.
selftest-route-git-token:
name: exercise the route-git-token action
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: route a github.com namespace (defaults)
uses: ./.github/actions/route-git-token
with:
token: not-a-real-token
path: octo-test
- name: route a self-hosted GitLab group, appending
uses: ./.github/actions/route-git-token
with:
token: second-fake-token
host: gitlab.example.com:8443
username: oauth2
path: my-group/sub-group
- name: the rewrites resolve and cargo is switched to the git CLI
shell: bash
run: |
set -euo pipefail
[ "$GIT_CONFIG_COUNT" = "2" ] || { echo "::error::expected 2 entries, got ${GIT_CONFIG_COUNT:-none}"; exit 1; }
[ "$CARGO_NET_GIT_FETCH_WITH_CLI" = "true" ] || { echo "::error::cargo git-CLI switch missing"; exit 1; }
got="$(git config --get 'url.https://x-access-token:not-a-real-token@github.com/octo-test/.insteadOf')"
[ "$got" = "https://github.com/octo-test/" ] || { echo "::error::github rewrite did not resolve (got '$got')"; exit 1; }
got="$(git config --get 'url.https://oauth2:second-fake-token@gitlab.example.com:8443/my-group/sub-group/.insteadOf')"
[ "$got" = "https://gitlab.example.com:8443/my-group/sub-group/" ] || { echo "::error::gitlab rewrite did not resolve (got '$got')"; exit 1; }
- name: an invalid path is refused
id: bad-path
continue-on-error: true
uses: ./.github/actions/route-git-token
with:
token: t
path: "evil/../traversal"
- name: an invalid host is refused
id: bad-host
continue-on-error: true
uses: ./.github/actions/route-git-token
with:
token: t
host: "github.com/../evil"
# The token is interpolated into a $GITHUB_ENV line, so a newline in it
# would export an environment variable of its own to every later step,
# and `::add-mask::` would only have covered the first line.
- name: a token carrying a newline is refused
id: bad-token
continue-on-error: true
uses: ./.github/actions/route-git-token
with:
token: "ghs_looks_fine\nGIT_CONFIG_COUNT=99"
path: octo-test
- name: the refusals actually happened
shell: bash
run: |
[ "${{ steps.bad-path.outcome }}" = "failure" ] \
|| { echo "::error::invalid path was accepted"; exit 1; }
[ "${{ steps.bad-host.outcome }}" = "failure" ] \
|| { echo "::error::invalid host was accepted"; exit 1; }
[ "${{ steps.bad-token.outcome }}" = "failure" ] \
|| { echo "::error::a token containing a newline was accepted"; exit 1; }
# …and it changed nothing: the two good entries above still stand.
[ "$GIT_CONFIG_COUNT" = "2" ] \
|| { echo "::error::a refused token disturbed the exported entries (count ${GIT_CONFIG_COUNT:-none})"; exit 1; }
# The timing actions end to end: marks bound stages, the sampler attributes
# CPU to the stage that spent it, and the report renders and closes the last
# stage itself. The CPU assertion is the one that matters: a load average
# would smear an eight-second burn across the stage after it, which is the
# failure the /proc/stat delta exists to avoid, so it burns every core in one
# stage and sleeps through the next, then asserts the ordering rather than any
# absolute number.
selftest-timing:
name: exercise the timing actions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: timing-start
id: start
uses: ./.github/actions/timing-start
with:
sample-interval: "1"
first-stage: setup
- name: timing-start exported the state directory
run: |
set -euo pipefail
test -n "${RUST_CI_TIMING_DIR:-}" || { echo "::error::RUST_CI_TIMING_DIR not exported"; exit 1; }
test "${{ steps.start.outputs.timing-dir }}" = "$RUST_CI_TIMING_DIR" \
|| { echo "::error::the timing-dir output disagrees with the exported env"; exit 1; }
for f in marks.tsv samples.tsv notes.tsv sampler.pid; do
test -f "$RUST_CI_TIMING_DIR/$f" || { echo "::error::missing $f"; exit 1; }
done
grep -q " setup$" "$RUST_CI_TIMING_DIR/marks.tsv" \
|| { echo "::error::the first-stage mark was not recorded"; exit 1; }
echo "✓ state at $RUST_CI_TIMING_DIR, sampler pid $(cat "$RUST_CI_TIMING_DIR/sampler.pid")"
- name: timing-mark (busy stage)
uses: ./.github/actions/timing-mark
with:
name: busy stage
# Saturate every core for long enough to cover several sampling ticks.
- name: burn all cores
run: |
for _ in $(seq 1 "$(nproc)"); do
timeout 8 bash -c 'while :; do :; done' &
done
wait
- name: timing-mark (idle stage)
uses: ./.github/actions/timing-mark
with:
name: idle stage
- name: sleep
run: sleep 8
- name: the sampler collected ticks while the steps ran
run: |
set -euo pipefail
n=$(wc -l < "$RUST_CI_TIMING_DIR/samples.tsv")
test "$n" -ge 10 || { echo "::error::only $n samples across ~17s at 1s; the sampler did not survive the step boundary"; exit 1; }
echo "✓ $n samples"
- name: timing-report
id: report
uses: ./.github/actions/timing-report
with:
columns: "10"
- name: the report attributed CPU to the stage that spent it
run: |
set -euo pipefail
stats="$RUST_CI_TIMING_DIR/stages.tsv"
test -f "$stats" || { echo "::error::no per-stage stats were written"; exit 1; }
cat "$stats"
busy=$(awk -F'\t' '$1 == "busy stage" {print int($3)}' "$stats")
idle=$(awk -F'\t' '$1 == "idle stage" {print int($3)}' "$stats")
test -n "$busy" && test -n "$idle" \
|| { echo "::error::a stage is missing from the report"; exit 1; }
test "$busy" -gt "$idle" \
|| { echo "::error::peak CPU did not separate the stages (busy ${busy}%, idle ${idle}%): samples are not being attributed per stage"; exit 1; }
test "$busy" -ge 50 \
|| { echo "::error::burning every core measured only ${busy}%"; exit 1; }
echo "✓ busy ${busy}% against idle ${idle}%"
# Asserted against the rendered report on disk, NOT $GITHUB_STEP_SUMMARY:
# that path is a separate file per step, so a later step opening it reads
# an empty file and every assertion here would fail while the report was
# in fact correct.
- name: the report rendered the table and the outputs agree with it
run: |
set -euo pipefail
report='${{ steps.report.outputs.report-path }}'
test -n "$report" && test -f "$report" \
|| { echo "::error::the report-path output does not name a file: '$report'"; exit 1; }
cat "$report"
grep -q '^## Where the time went' "$report" \
|| { echo "::error::no report heading"; exit 1; }
grep -q '| busy stage |' "$report" \
|| { echo "::error::the busy stage is missing from the rendered table"; exit 1; }
# The sentinel closes the last stage and is not itself a row.
if grep -q '__report' "$report"; then
echo "::error::the internal sentinel leaked into the table"; exit 1
fi
total='${{ steps.report.outputs.total-seconds }}'
test "$total" -ge 15 || { echo "::error::total of ${total}s is short of the ~17s of stages"; exit 1; }
echo "✓ rendered; total ${total}s, slowest '${{ steps.report.outputs.slowest-stage }}'"
- name: the report stopped the sampler
run: |
set -euo pipefail
test ! -f "$RUST_CI_TIMING_DIR/sampler.pid" \
|| { echo "::error::the pid file survived the report"; exit 1; }
before=$(wc -l < "$RUST_CI_TIMING_DIR/samples.tsv")
sleep 3
after=$(wc -l < "$RUST_CI_TIMING_DIR/samples.tsv")
test "$before" = "$after" \
|| { echo "::error::the sampler is still writing ($before -> $after); it would outlive the job"; exit 1; }
echo "✓ sampler stopped"
# Durations without sampling, and a report with nothing to report: the two
# shapes a consumer hits on a non-Linux runner and on a misordered workflow.
selftest-timing-degenerate:
name: exercise the timing actions without samples
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Negative control: with no marks and no API access, timing-report must
# warn and pass, not fail the job. A timing action is instrumentation; it
# has no business turning a green build red. token: "" denies the Actions
# API, so this stays a genuine no-data case rather than deriving the run's
# own steps (the API path is exercised by "from the Actions API" below).
- name: timing-report without marks (negative control)
uses: ./.github/actions/timing-report
with:
token: ""
- name: the empty report wrote nothing and did not fail
run: |
set -euo pipefail
report="${RUNNER_TEMP}/rust-ci-timing/report.md"
if [ -e "$report" ]; then
echo "::error::a report was rendered with no marks"; cat "$report"; exit 1
fi
echo "✓ no marks, no report, no failure"
- name: timing-start with sampling off
uses: ./.github/actions/timing-start
with:
sample-interval: "0"
first-stage: unsampled setup
- name: sampling off starts no sampler
run: |
set -euo pipefail
test ! -f "$RUST_CI_TIMING_DIR/sampler.pid" \
|| { echo "::error::sample-interval 0 still started a sampler"; exit 1; }
grep -q 'sampling disabled' "$RUST_CI_TIMING_DIR/notes.tsv" \
|| { echo "::error::the report will not say why there are no samples"; exit 1; }
echo "✓ no sampler, and the reason is recorded"
- uses: ./.github/actions/timing-mark
with:
name: a stage
- run: sleep 2
- name: timing-report
id: report
uses: ./.github/actions/timing-report
with:
order: chronological
- name: the table degrades to durations only
run: |
set -euo pipefail
report='${{ steps.report.outputs.report-path }}'
cat "$report"
grep -q '| stage | duration | share |' "$report" \
|| { echo "::error::expected the durations-only header without samples"; exit 1; }
if grep -q 'cpu shape' "$report"; then
echo "::error::CPU columns rendered with no samples behind them"; exit 1
fi
grep -q 'sampling disabled by input' "$report" \
|| { echo "::error::the report did not say sampling was off"; exit 1; }
echo "✓ durations only, and it says why"
# The API-derived path: with no manual marks and an actions:read token,
# timing-report reads the job's own per-step timings from the Actions API,
# which is what a workflow opts into by dropping its timing-mark steps. No
# timing-start here either, so this also covers the no-sampler degrade.
selftest-timing-from-api:
name: exercise the timing actions from the Actions API
runs-on: ubuntu-latest
permissions:
# contents too: a permissions block replaces the default set, and the
# checkout of a private clone of this repository needs the read.
contents: read
actions: read
steps:
- uses: actions/checkout@v7
- name: spend a measurable moment
run: sleep 3
- name: timing-report
id: report
uses: ./.github/actions/timing-report
- name: the stages are the job's real step names, with no marks placed
run: |
set -euo pipefail
report='${{ steps.report.outputs.report-path }}'
test -e "$report" || { echo "::error::no report was produced from the API"; exit 1; }
cat "$report"
# "Set up job" is always the first step and completes before this one,
# so the API has it regardless of any lag on the freshest step.
grep -q 'Set up job' "$report" \
|| { echo "::error::the API-derived report is missing the job's real step names"; exit 1; }
echo "✓ stages derived from the Actions API without any timing-mark"
# first-stage: "" starts the sampler but lays down no mark, so a job keeps the
# CPU/memory shape while still deriving its stages from the Actions API and the
# setup a workflow adopts when it drops its timing-mark steps but wants the
# sampler. The assertion would fail if timing-start still marked: an empty-named
# mark would send timing-report down the marks path, and "Set up job" only
# appears when the stages come from the API.
selftest-timing-sampler-only:
name: exercise sampler-only timing-start with the Actions API
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@v7
- name: timing-start (sampler only, no first-stage mark)
uses: ./.github/actions/timing-start
with:
first-stage: ""
- name: spend a measurable moment
run: sleep 3
- name: timing-report
id: report
uses: ./.github/actions/timing-report
- name: the report comes from the API even though the sampler ran
run: |
set -euo pipefail
report='${{ steps.report.outputs.report-path }}'
test -e "$report" || { echo "::error::no report was produced"; exit 1; }
cat "$report"
grep -q 'Set up job' "$report" \
|| { echo "::error::sampler-only timing-start left a mark; the API path was not taken"; exit 1; }
echo "✓ sampler-only timing-start kept the API-derived stages"
# rust-cache's opt-in statistics: the hit kind and the restored size reach the
# timing notes, which is what lets a report distinguish a cold run from a warm
# one that simply could not reuse what it restored.
selftest-cache-stats:
name: exercise the cache statistics
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/timing-start
with:
sample-interval: "0"
first-stage: setup
- uses: ./.github/actions/rust-cache
with:
prefix: stats-fixture
cache-target: "true"
target-dir: fixtures/sample-crate/target
lockfiles: fixtures/sample-crate/Cargo.lock
stats: "true"
- name: the restore recorded a hit kind and, on a hit, a size
run: |
set -euo pipefail
notes="$RUST_CI_TIMING_DIR/notes.tsv"
cat "$notes"
kind=$(awk -F'\t' '$1 == "cache.target" {print $2}' "$notes" | tail -1)
case "$kind" in
'exact hit' | 'restore-key fallback'* | miss) echo "✓ hit kind: $kind" ;;
*) echo "::error::unexpected or missing hit kind: '$kind'"; exit 1 ;;
esac
grep -q '^cache\.target\.key stats-fixture-Linux-target-' "$notes" \
|| { echo "::error::the cache key was not recorded"; exit 1; }
if [ "$kind" != "miss" ]; then
grep -q '^cache\.target\.restored ' "$notes" \
|| { echo "::error::a restored tree recorded no size"; exit 1; }
fi
- id: report
uses: ./.github/actions/timing-report
- name: the report carries a Cache section
run: |
set -euo pipefail
report='${{ steps.report.outputs.report-path }}'
cat "$report"
grep -q '^### Cache' "$report" \
|| { echo "::error::the Cache section is missing from the report"; exit 1; }
# shellcheck disable=SC2016 # the single quotes are the point
grep -q '`cache.target`' "$report" \
|| { echo "::error::the hit kind did not reach the report"; exit 1; }
echo "✓ Cache section rendered"