Skip to content

sccache: the client waits for the server through sccache's own setting #183

sccache: the client waits for the server through sccache's own setting

sccache: the client waits for the server through sccache's own setting #183

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: rust-ci-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
*/rust-ci-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/rust_ci_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: rust-ci-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/rust-ci-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: rust-ci-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: rust-ci-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: rust-ci-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: rust-ci-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: rust-ci-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: rust-ci-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
id: cache
with:
prefix: localoff-${{ github.run_id }}
cache-target: "true"
local-target: "auto"
cache-registry: "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; }
[ "${{ steps.cache.outputs.registry-cached }}" = "true" ] || { echo "::error::registry-cached is '${{ steps.cache.outputs.registry-cached }}', expected true"; exit 1; }
echo "✓ local-target and cache-registry 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 and in the step summary
run: |
notes="$RUNNER_TEMP/rust-ci-timing/notes.tsv"
grep -q '^cache\.sccache' "$notes" || { echo "::error::no cache.sccache fact in $notes"; exit 1; }
# The runner owns $GITHUB_STEP_SUMMARY and a later step cannot read it
# back, so the writer runs once more by hand against a readable file.
S="$RUNNER_TEMP/sccache-summary.md"
GITHUB_ACTION_PATH=.github/actions/sccache-stats RUNNER_TEMP="$RUNNER_TEMP/sccache-unit" GITHUB_STEP_SUMMARY="$S" \
bash .github/actions/sccache-stats/sccache-stats.sh >/dev/null
cat "$S"
# shellcheck disable=SC2016 # the backticks are Markdown, not a subshell
grep -q '| `cache.sccache` | .* hits / .* misses of ' "$S" \
|| { echo "::error::the sccache facts did not reach the step summary"; exit 1; }
echo "✓ $(grep -c '^cache\.sccache' "$notes") fact(s) recorded, and rendered in the step summary"
# 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: rust-cache with cache-registry auto skips the registry entry
id: cache
uses: ./.github/actions/rust-cache
with:
prefix: mirrored-${{ github.run_id }}
cache-registry: "auto"
save: "false"
lockfiles: fixtures/sample-crate/Cargo.lock
- name: the registry entry was skipped
run: |
[ "${{ steps.cache.outputs.registry-cached }}" = "false" ] || { echo "::error::registry-cached is '${{ steps.cache.outputs.registry-cached }}', expected false"; exit 1; }
echo "✓ registry archive skipped in favour of the 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"
# publish-draft-release seals the final tag against the newest candidate marker
# by tree, through the API. A fake gh answers its four reads from a scratch
# repository: an empty commit on the marker's commit carries the same tree and
# passes, a commit with different content is refused, the newest marker wins by
# numeric rcN order (rc10 over rc2), and a version without markers is refused.
selftest-publish-seal:
name: publish-draft-release seals by tree
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: a scratch repository, candidate markers and a fake gh
run: |
set -euo pipefail
R="$RUNNER_TEMP/seal"; rm -rf "$R"; mkdir -p "$R" "$RUNNER_TEMP/sealbin"
g() { git -C "$R" -c user.name=selftest -c user.email=selftest@invalid.example "$@"; }
g init -q -b main
echo one > "$R/file"; g add file; g commit -qm one
g tag -a -m "candidate 1" v9.9.0-rc1
g tag v9.9.0-rc2
g commit -q --allow-empty -m "rebase-merged merge-back: the same tree"
echo "SAME=$(g rev-parse HEAD)" >> "$GITHUB_ENV"
echo two > "$R/file"; g commit -qam two
echo "OTHER=$(g rev-parse HEAD)" >> "$GITHUB_ENV"
cat > "$RUNNER_TEMP/sealbin/gh" <<'FAKE'
#!/usr/bin/env bash
# Answers publish-draft-release's API reads from the repository in the cwd;
# the --jq selectors are implied by which endpoint is asked.
all="$*"
case "$all" in
*"git/matching-refs/tags/"*) p="${all##*git/matching-refs/tags/}"; p="${p%% *}"; git for-each-ref --format='%(refname)' "refs/tags/${p}*" ;;
*"git/ref/tags/"*) t="${all##*git/ref/tags/}"; t="${t%% *}"; git rev-parse "refs/tags/$t" ;;
*"git/tags/"*) s="${all##*git/tags/}"; s="${s%% *}"; [ "$(git cat-file -t "$s")" = tag ] || exit 1; git rev-parse "$s^{commit}" ;;
*"commits/"*) s="${all##*commits/}"; s="${s%% *}"; git rev-parse "$s^{tree}" ;;
*) echo "fake gh: unexpected call: $all" >&2; exit 1 ;;
esac
FAKE
chmod +x "$RUNNER_TEMP/sealbin/gh"
- name: same tree sealed, other tree refused, rc10 outranks rc2, missing marker refused
working-directory: ${{ runner.temp }}/seal
env:
GITHUB_REPOSITORY: selftest/seal
run: |
set -euo pipefail
export PATH="$RUNNER_TEMP/sealbin:$PATH" GITHUB_OUTPUT="$RUNNER_TEMP/seal.out"
seal() { INPUT_VERSION="$1" INPUT_TAG_SHA="$2" INPUT_SEAL_ONLY=true bash "$GITHUB_WORKSPACE/.github/actions/publish-draft-release/publish-draft-release.sh" > "$RUNNER_TEMP/seal.log" 2>&1; }
seal 9.9.0 "$SAME" || { cat "$RUNNER_TEMP/seal.log"; echo "::error::the seal refused a commit with the marker's tree"; exit 1; }
grep -q '^marker=v9.9.0-rc2$' "$GITHUB_OUTPUT" || { echo "::error::the newest marker was not rc2: $(cat "$GITHUB_OUTPUT")"; exit 1; }
if seal 9.9.0 "$OTHER"; then echo "::error::the seal accepted a different tree"; exit 1; fi
grep -q 'does not carry the content' "$RUNNER_TEMP/seal.log" || { cat "$RUNNER_TEMP/seal.log"; exit 1; }
git -c user.name=selftest -c user.email=selftest@invalid.example tag -a -m "candidate 10" v9.9.0-rc10 "$OTHER"
if seal 9.9.0 "$SAME"; then echo "::error::rc10 did not outrank rc2"; exit 1; fi
seal 9.9.0 "$OTHER" || { cat "$RUNNER_TEMP/seal.log"; echo "::error::the seal refused the rc10 commit"; exit 1; }
if seal 8.8.8 "$OTHER"; then echo "::error::a version without markers was sealed"; exit 1; fi
grep -q 'no candidate marker' "$RUNNER_TEMP/seal.log" || { cat "$RUNNER_TEMP/seal.log"; exit 1; }
echo "✓ same tree sealed against rc2, other tree refused, rc10 outranks rc2, missing marker refused"
# 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: the report takes its stage boundaries from
# the job's own steps through the Actions API, and the sampler attributes CPU
# to the step that spent it. The CPU assertion is the one that matters: a
# load average would smear an eight-second burn across the step after it,
# which is the failure the /proc/stat delta exists to avoid, so it burns every
# core in one step and sleeps through the next, then asserts the ordering of
# the two means rather than any absolute number.
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: timing-start
id: start
uses: ./.github/actions/timing-start
with:
sample-interval: "1"
- name: timing-start laid down the state
run: |
set -euo pipefail
dir='${{ steps.start.outputs.timing-dir }}'
test "$dir" = "$RUNNER_TEMP/rust-ci-timing" \
|| { echo "::error::the timing-dir output is not the shared location: '$dir'"; exit 1; }
for f in samples.tsv notes.tsv sampler.pid; do
test -f "$dir/$f" || { echo "::error::missing $f"; exit 1; }
done
grep -q '^started [0-9]' "$dir/notes.tsv" \
|| { echo "::error::the job start was not recorded"; exit 1; }
echo "✓ state at $dir, sampler pid $(cat "$dir/sampler.pid")"
# 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: sleep
run: sleep 8
- name: the sampler collected ticks while the steps ran
run: |
set -euo pipefail
n=$(wc -l < "$RUNNER_TEMP/rust-ci-timing/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"
# The Actions API lists a step a few seconds after it completes; a short
# final step keeps the two asserted steps out of that window.
- name: settle
run: sleep 5
- name: timing-report
id: report
uses: ./.github/actions/timing-report
with:
columns: "10"
# Means, not peaks: the API places step boundaries at whole seconds, so
# the first tick inside "sleep" can still carry the burn's last moments.
- name: the report attributed CPU to the step that spent it
run: |
set -euo pipefail
stats="$RUNNER_TEMP/rust-ci-timing/stages.tsv"
test -f "$stats" || { echo "::error::no per-stage stats were written"; exit 1; }
cat "$stats"
busy=$(awk -F'\t' '$1 == "burn all cores" {print int($4)}' "$stats")
idle=$(awk -F'\t' '$1 == "sleep" {print int($4)}' "$stats")
test -n "$busy" && test -n "$idle" \
|| { echo "::error::a step is missing from the API-derived stages"; exit 1; }
test "$busy" -gt "$idle" \
|| { echo "::error::mean CPU did not separate the steps (burn ${busy}%, sleep ${idle}%): samples are not being attributed per stage"; exit 1; }
test "$busy" -ge 50 \
|| { echo "::error::burning every core measured a mean of only ${busy}%"; exit 1; }
echo "✓ burn ${busy}% against sleep ${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; }
# "Set up job" is always the first step and completes before any of
# ours, 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; }
grep -q '| burn all cores |' "$report" \
|| { echo "::error::the burn step 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 steps"; exit 1; }
test '${{ steps.report.outputs.total-ms }}' -ge 15000 \
|| { echo "::error::total-ms disagrees with total-seconds"; 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 "$RUNNER_TEMP/rust-ci-timing/sampler.pid" \
|| { echo "::error::the pid file survived the report"; exit 1; }
before=$(wc -l < "$RUNNER_TEMP/rust-ci-timing/samples.tsv")
sleep 3
after=$(wc -l < "$RUNNER_TEMP/rust-ci-timing/samples.tsv")
test "$before" = "$after" \
|| { echo "::error::the sampler is still writing ($before -> $after); it would outlive the job"; exit 1; }
echo "✓ sampler stopped"
# The report without the Actions API, the shape a consumer sees before the
# job grants actions: read: timing-start with its defaults, then a report that
# shows the job totals, says how to get per-step stages, and passes. A timing
# action is instrumentation; it has no business turning a green build red.
# token: "" denies the API deterministically; on a public repository the jobs
# endpoint answers without one.
selftest-timing-no-api:
name: exercise timing-report without the Actions API
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/timing-start
- name: spend a measurable moment
run: sleep 6
- name: timing-report
id: report
uses: ./.github/actions/timing-report
with:
token: ""
- name: the report shows the job totals and did not fail
run: |
set -euo pipefail
report='${{ steps.report.outputs.report-path }}'
test -n "$report" && test -f "$report" \
|| { echo "::error::no report was rendered without the API"; exit 1; }
cat "$report"
grep -q 'for the whole job' "$report" \
|| { echo "::error::the totals line is missing"; exit 1; }
grep -q 'actions: read' "$report" \
|| { echo "::error::the report does not say how to get per-step stages"; exit 1; }
if grep -q 'Set up job' "$report"; then
echo "::error::stages came from the API with an empty token"; exit 1
fi
test '${{ steps.report.outputs.slowest-stage }}' = "$GITHUB_JOB" \
|| { echo "::error::the single stage is not named after the job: '${{ steps.report.outputs.slowest-stage }}'"; exit 1; }
total='${{ steps.report.outputs.total-seconds }}'
test "$total" -ge 6 || { echo "::error::total of ${total}s is short of the 6s spent"; exit 1; }
test ! -f "$RUNNER_TEMP/rust-ci-timing/sampler.pid" \
|| { echo "::error::the pid file survived the report"; exit 1; }
echo "✓ totals rendered without the API; ${total}s"
# 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"
- uses: ./.github/actions/rust-cache
id: 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="$RUNNER_TEMP/rust-ci-timing/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
# The same facts land in the step's own summary, for a job without
# timing-report. The runner owns $GITHUB_STEP_SUMMARY and a later step
# cannot read it back, so the writer runs once more by hand against a
# readable file, with its state kept apart from the job's notes.
- name: the stats writer puts the same facts into the step summary
env:
TARGET_DIR: fixtures/sample-crate/target
TARGET_HIT: ${{ steps.cache.outputs.target-cache-hit }}
TARGET_KEY: stats-fixture-summary-check
TARGET_LOCAL: "false"
REGISTRY_DIRS: ""
run: |
set -euo pipefail
S="$RUNNER_TEMP/cache-summary.md"
GITHUB_ACTION_PATH=.github/actions/rust-cache RUNNER_TEMP="$RUNNER_TEMP/cache-unit" \
GITHUB_STEP_SUMMARY="$S" GITHUB_ENV=/dev/null \
bash .github/actions/rust-cache/cache-stats.sh
cat "$S"
grep -q '^### Cache restore' "$S" \
|| { echo "::error::no heading in the step summary"; exit 1; }
# shellcheck disable=SC2016 # the backticks are Markdown, not a subshell
grep -q '| `cache.target` | ' "$S" \
|| { echo "::error::the hit kind did not reach the step summary"; exit 1; }
# shellcheck disable=SC2016
grep -q '| `cache.target.key` | stats-fixture-summary-check |' "$S" \
|| { echo "::error::the cache key did not reach the step summary"; exit 1; }
echo "✓ the facts render in the step summary without timing-report"
- 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; }
# sample-interval "0" above: the report says so and draws no CPU columns.
grep -q 'sampling disabled by input' "$report" \
|| { echo "::error::the report did not say sampling was off"; exit 1; }
if grep -q 'cpu shape' "$report"; then
echo "::error::CPU columns rendered with no samples behind them"; exit 1
fi
echo "✓ Cache section rendered, durations only"