Repository navigation
changelog: the audit fixes under Unreleased #164
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| # 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. | |
| # promote-release: mode resolution against canned rulesets (a PATH-shimmed | |
| # fake gh — no real releases touched) and the off-mode promotion end to end | |
| # against a scratch origin. | |
| selftest-promote-release: | |
| name: exercise the promote-release modes | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: stage a scratch repository with a marker, and a fake gh | |
| run: | | |
| set -euo pipefail | |
| rm -rf "$RUNNER_TEMP/promote" "$RUNNER_TEMP/promote-origin.git" "$RUNNER_TEMP/fakebin" | |
| git init -q --bare "$RUNNER_TEMP/promote-origin.git" | |
| mkdir -p "$RUNNER_TEMP/promote" "$RUNNER_TEMP/fakebin" | |
| cd "$RUNNER_TEMP/promote" | |
| git init -q | |
| git remote add origin "$RUNNER_TEMP/promote-origin.git" | |
| git -c user.name=selftest -c user.email=selftest@invalid.example commit -q --allow-empty -m release | |
| git -c user.name=selftest -c user.email=selftest@invalid.example tag -a -m "v9.9.0 | |
| Added: | |
| - The thing." v9.9.0-rc1 | |
| git push -q origin HEAD v9.9.0-rc1 | |
| cat > "$RUNNER_TEMP/fakebin/gh" <<'FAKE' | |
| #!/usr/bin/env bash | |
| case "$*" in | |
| *"/rulesets "*|*"/rulesets") if [ "${FAKE_RULESETS_FAIL:-}" = "1" ]; then exit 1; fi; printf '%s' "${FAKE_RULESET_IDS:-}" ;; | |
| *"/rulesets/"*) if [ "${FAKE_RULESET_DETAIL_FAIL:-}" = "1" ]; then exit 1; fi; printf '%s\n' "${FAKE_RULESET_JSON:-}" ;; | |
| *".default_branch"*) printf 'main\n' ;; | |
| "release edit"*) echo "FLIPPED: $*" >> "${FAKE_LOG:?}" ;; | |
| *) echo "fake gh: unhandled: $*" >&2; exit 64 ;; | |
| esac | |
| FAKE | |
| chmod +x "$RUNNER_TEMP/fakebin/gh" | |
| - name: every manual road ends at the release manager | |
| run: | | |
| set -euo pipefail | |
| cd "$RUNNER_TEMP/promote" | |
| COVERING='{"rules":[{"type":"required_signatures"}],"conditions":{"ref_name":{"include":["~ALL"],"exclude":["refs/tags/v*-rc*"]}}}' | |
| run_promote() { # sign-tags moving-major ids json list-fail detail-fail | |
| PATH="$RUNNER_TEMP/fakebin:$PATH" FAKE_LOG="$RUNNER_TEMP/gh.log" \ | |
| GITHUB_ACTION_PATH="$GITHUB_WORKSPACE/.github/actions/promote-release" \ | |
| GITHUB_REPOSITORY=selftest/promote GITHUB_SHA="$(git rev-parse HEAD)" \ | |
| GITHUB_OUTPUT="$RUNNER_TEMP/out.txt" GITHUB_STEP_SUMMARY="$RUNNER_TEMP/summary.md" \ | |
| INPUT_VERSION=9.9.0 INPUT_MARKER_TAG=v9.9.0-rc1 INPUT_SIGN_TAGS="$1" \ | |
| INPUT_MOVING_MAJOR="$2" INPUT_GIT_USER_NAME=selftest INPUT_GIT_USER_EMAIL=selftest@invalid.example \ | |
| FAKE_RULESET_IDS="$3" FAKE_RULESET_JSON="$4" FAKE_RULESETS_FAIL="$5" FAKE_RULESET_DETAIL_FAIL="$6" \ | |
| bash "$GITHUB_WORKSPACE/.github/actions/promote-release/promote-release.sh" | |
| } | |
| : > "$RUNNER_TEMP/out.txt"; run_promote manual false "" "" "" "" | |
| grep -qx 'mode=manual' "$RUNNER_TEMP/out.txt" | |
| : > "$RUNNER_TEMP/out.txt"; run_promote "" false "7" "$COVERING" "" "" | |
| grep -qx 'mode=manual' "$RUNNER_TEMP/out.txt" | |
| : > "$RUNNER_TEMP/out.txt"; run_promote "" false "" "" "1" "" | |
| grep -qx 'mode=manual' "$RUNNER_TEMP/out.txt" | |
| # an unreadable ruleset detail is as unknown as an unreadable listing | |
| : > "$RUNNER_TEMP/out.txt"; run_promote "" false "7" "" "" "1" | |
| grep -qx 'mode=manual' "$RUNNER_TEMP/out.txt" | |
| # a condition pattern the probe cannot faithfully evaluate is unknown, | |
| # not "does not match" — it must never read as "no signature required" | |
| : > "$RUNNER_TEMP/out.txt" | |
| run_promote "" false "7" '{"rules":[{"type":"required_signatures"}],"conditions":{"ref_name":{"include":["refs/tags/v[!x]*"],"exclude":[]}}}' "" "" | |
| grep -qx 'mode=manual' "$RUNNER_TEMP/out.txt" | |
| # explicit off colliding with a covering signature rule errors before anything is pushed | |
| if run_promote off false "7" "$COVERING" "" "" > "$RUNNER_TEMP/collision.log" 2>&1; then | |
| echo "::error::sign-tags: off was not refused under a covering signature rule"; exit 1 | |
| fi | |
| grep -q 'requires signatures on v9.9.0' "$RUNNER_TEMP/collision.log" | |
| test -z "$(git ls-remote origin refs/tags/v9.9.0)" | |
| echo "✓ manual roads defer; the off-collision errors early; nothing was pushed" | |
| # The canned rule requires signatures only on v*-sig companions — the | |
| # pattern-aware detection resolves plain versions to off and promotes. | |
| - name: off promotes — tag with the marker message, flip, moving major | |
| run: | | |
| set -euo pipefail | |
| cd "$RUNNER_TEMP/promote" | |
| : > "$RUNNER_TEMP/out.txt"; : > "$RUNNER_TEMP/gh.log" | |
| PATH="$RUNNER_TEMP/fakebin:$PATH" FAKE_LOG="$RUNNER_TEMP/gh.log" \ | |
| GITHUB_ACTION_PATH="$GITHUB_WORKSPACE/.github/actions/promote-release" \ | |
| GITHUB_REPOSITORY=selftest/promote GITHUB_SHA="$(git rev-parse HEAD)" \ | |
| GITHUB_OUTPUT="$RUNNER_TEMP/out.txt" \ | |
| INPUT_VERSION=9.9.0 INPUT_MARKER_TAG=v9.9.0-rc1 INPUT_SIGN_TAGS="" \ | |
| INPUT_MOVING_MAJOR=true INPUT_GIT_USER_NAME=selftest INPUT_GIT_USER_EMAIL=selftest@invalid.example \ | |
| FAKE_RULESET_IDS="7" FAKE_RULESET_JSON='{"rules":[{"type":"required_signatures"}],"conditions":{"ref_name":{"include":["refs/tags/v[0-9]*.[0-9]*.[0-9]*-sig"],"exclude":[]}}}' \ | |
| FAKE_RULESETS_FAIL="" FAKE_RULESET_DETAIL_FAIL="" \ | |
| bash "$GITHUB_WORKSPACE/.github/actions/promote-release/promote-release.sh" | |
| grep -qx 'promoted=true' "$RUNNER_TEMP/out.txt" | |
| grep -q 'FLIPPED: release edit v9.9.0 --draft=false --prerelease=false' "$RUNNER_TEMP/gh.log" | |
| test -n "$(git ls-remote origin refs/tags/v9.9.0)" | |
| test -n "$(git ls-remote origin refs/tags/v9)" | |
| test "$(git tag -l --format='%(contents:subject)' v9.9.0)" = "v9.9.0" | |
| echo "✓ off-mode promotion tagged, flipped, and advanced the major" | |
| # The decision is made before anything is pushed, and only the highest | |
| # stable in the line advances the major: a backport publishes without | |
| # touching it. | |
| - name: a backport publishes without advancing the major | |
| run: | | |
| set -euo pipefail | |
| cd "$RUNNER_TEMP/promote" | |
| git -c user.name=selftest -c user.email=selftest@invalid.example commit -q --allow-empty -m backport | |
| git -c user.name=selftest -c user.email=selftest@invalid.example tag -a -m "v9.8.1 backport" v9.8.1-rc1 | |
| git push -q origin HEAD v9.8.1-rc1 | |
| major_before="$(git ls-remote origin refs/tags/v9 | cut -f1)" | |
| : > "$RUNNER_TEMP/out.txt"; : > "$RUNNER_TEMP/gh.log" | |
| PATH="$RUNNER_TEMP/fakebin:$PATH" FAKE_LOG="$RUNNER_TEMP/gh.log" \ | |
| GITHUB_ACTION_PATH="$GITHUB_WORKSPACE/.github/actions/promote-release" \ | |
| GITHUB_REPOSITORY=selftest/promote GITHUB_SHA="$(git rev-parse HEAD)" \ | |
| GITHUB_OUTPUT="$RUNNER_TEMP/out.txt" \ | |
| INPUT_VERSION=9.8.1 INPUT_MARKER_TAG=v9.8.1-rc1 INPUT_SIGN_TAGS="" \ | |
| INPUT_MOVING_MAJOR=true INPUT_GIT_USER_NAME=selftest INPUT_GIT_USER_EMAIL=selftest@invalid.example \ | |
| FAKE_RULESET_IDS="" FAKE_RULESET_JSON="" FAKE_RULESETS_FAIL="" FAKE_RULESET_DETAIL_FAIL="" \ | |
| bash "$GITHUB_WORKSPACE/.github/actions/promote-release/promote-release.sh" | |
| grep -qx 'promoted=true' "$RUNNER_TEMP/out.txt" | |
| grep -q 'FLIPPED: release edit v9.8.1 --draft=false --prerelease=false' "$RUNNER_TEMP/gh.log" | |
| test -n "$(git ls-remote origin refs/tags/v9.8.1)" | |
| test "$(git ls-remote origin refs/tags/v9 | cut -f1)" = "$major_before" | |
| echo "✓ the backport published; v9 kept pointing where it was" | |
| # A completed promotion re-runs as a skip — same version, same commit, no | |
| # second tag push, the flip and the major still exercised. The same | |
| # version on a different commit is a named conflict. | |
| - name: re-running a completed promotion is a skip | |
| run: | | |
| set -euo pipefail | |
| cd "$RUNNER_TEMP/promote" | |
| rerun() { | |
| PATH="$RUNNER_TEMP/fakebin:$PATH" FAKE_LOG="$RUNNER_TEMP/gh.log" \ | |
| GITHUB_ACTION_PATH="$GITHUB_WORKSPACE/.github/actions/promote-release" \ | |
| GITHUB_REPOSITORY=selftest/promote GITHUB_SHA="$1" \ | |
| GITHUB_OUTPUT="$RUNNER_TEMP/out.txt" \ | |
| INPUT_VERSION=9.9.0 INPUT_MARKER_TAG=v9.9.0-rc1 INPUT_SIGN_TAGS="" \ | |
| INPUT_MOVING_MAJOR=true INPUT_GIT_USER_NAME=selftest INPUT_GIT_USER_EMAIL=selftest@invalid.example \ | |
| FAKE_RULESET_IDS="" FAKE_RULESET_JSON="" FAKE_RULESETS_FAIL="" FAKE_RULESET_DETAIL_FAIL="" \ | |
| bash "$GITHUB_WORKSPACE/.github/actions/promote-release/promote-release.sh" | |
| } | |
| before="$(git ls-remote origin refs/tags/v9.9.0 | head -1 | cut -f1)" | |
| : > "$RUNNER_TEMP/out.txt"; : > "$RUNNER_TEMP/gh.log" | |
| rerun "$(git rev-list -n1 refs/tags/v9.9.0)" | tee "$RUNNER_TEMP/rerun.log" | |
| grep -qx 'promoted=true' "$RUNNER_TEMP/out.txt" | |
| grep -q 'skipping the tag push' "$RUNNER_TEMP/rerun.log" | |
| grep -q 'FLIPPED: release edit v9.9.0 --draft=false --prerelease=false' "$RUNNER_TEMP/gh.log" | |
| test "$(git ls-remote origin refs/tags/v9.9.0 | head -1 | cut -f1)" = "$before" | |
| if rerun "$(git rev-parse HEAD)" > "$RUNNER_TEMP/conflict.log" 2>&1; then | |
| echo "::error::a different commit reused v9.9.0"; exit 1 | |
| fi | |
| grep -q 'a different promotion owns this version' "$RUNNER_TEMP/conflict.log" | |
| echo "✓ the re-run skipped the push; a different commit was refused" | |
| # The publish-draft guidance renders merge-then-publish with the -sig | |
| # attestation, and refuses at candidate time when a signature rule still | |
| # covers the version itself. | |
| - name: publish-draft guidance — render, and the covering-rule refusal | |
| run: | | |
| set -euo pipefail | |
| S="$RUNNER_TEMP/guidance-publish.md"; : > "$S" | |
| run_guidance() { # ids json | |
| PATH="$RUNNER_TEMP/fakebin:$PATH" \ | |
| GITHUB_ACTION_PATH="$GITHUB_WORKSPACE/.github/actions/release-guidance" \ | |
| GITHUB_REPOSITORY=selftest/promote GITHUB_STEP_SUMMARY="$S" \ | |
| INPUT_VERSION=9.9.0 INPUT_MARKER_TAG=v9.9.0-rc1 INPUT_COMMIT=abc0123 \ | |
| INPUT_GO_LIVE=publish-draft INPUT_TAG_SCRIPT='' INPUT_DRAFT_URL=https://example.invalid/v9.9.0 \ | |
| FAKE_RULESET_IDS="$1" FAKE_RULESET_JSON="$2" FAKE_RULESETS_FAIL="" FAKE_RULESET_DETAIL_FAIL="" \ | |
| bash "$GITHUB_WORKSPACE/.github/actions/release-guidance/release-guidance.sh" | |
| } | |
| run_guidance "" "" >/dev/null | |
| test "$(grep -c '^### ' "$S")" = "3" | |
| grep -q '1. Merge the merge-back pull request' "$S" | |
| # the publish command must pin the target: an unpinned publish tags | |
| # whatever the default branch points at when the click happens | |
| # shellcheck disable=SC2016 # the single quotes are the point | |
| grep -q 'gh release edit v9.9.0 --draft=false --prerelease=false --target "$target"' "$S" | |
| grep -q 'git ls-remote origin refs/heads/' "$S" | |
| grep -q 'v9.9.0-sig' "$S" | |
| grep -q 'before publishing' "$S" | |
| COVERING='{"rules":[{"type":"required_signatures"}],"conditions":{"ref_name":{"include":["~ALL"],"exclude":["refs/tags/v*-rc*"]}}}' | |
| if run_guidance "7" "$COVERING" > "$RUNNER_TEMP/guidance-refused.log" 2>&1; then | |
| echo "::error::publish-draft guidance was not refused under a covering signature rule"; exit 1 | |
| fi | |
| grep -q 'requires signatures on v9.9.0' "$RUNNER_TEMP/guidance-refused.log" | |
| echo "✓ the publish-draft guidance renders; a covering rule refuses at candidate time" | |
| # require-signed-release: the registry gate's three sources against a canned | |
| # gh — the release tag itself, an attestation companion on the same commit, | |
| # and the opt-in commit signature with the web-flow exclusion. Unsigned is a | |
| # green answer, not a failure. | |
| selftest-require-signed-release: | |
| name: exercise the registry signature gate | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: stage a canned gh | |
| run: | | |
| mkdir -p "$RUNNER_TEMP/rsrbin" | |
| cat > "$RUNNER_TEMP/rsrbin/gh" <<'FAKE' | |
| #!/usr/bin/env bash | |
| all="$*" | |
| refs="${FAKE_REFS:-null}" objs="${FAKE_TAG_OBJECTS:-null}" commits="${FAKE_COMMITS:-null}" | |
| case "$all" in | |
| *"git/matching-refs/tags/"*) printf '%s\n' "${FAKE_TAG_LIST:-[]}" ;; | |
| *"git/ref/tags/"*) t="${all##*git/ref/tags/}"; t="${t%% *}"; jq -e --arg t "$t" '.[$t]? // empty' <<<"$refs" ;; | |
| *"git/tags/"*) s="${all##*git/tags/}"; s="${s%% *}"; jq -e --arg s "$s" '.[$s]? // empty' <<<"$objs" ;; | |
| # only published releases answer by tag; a draft reserves nothing | |
| *"releases/tags/"*) t="${all##*releases/tags/}"; t="${t%% *}"; jq -e --arg t "$t" '.[$t]? | select(. == "published") | {tag_name: $t}' <<<"${FAKE_RELEASES:-null}" ;; | |
| "release view"*) t="${all#release view }"; t="${t%% *}"; jq -e --arg t "$t" 'if .[$t]? == "draft" then true else empty end' <<<"${FAKE_RELEASES:-null}" ;; | |
| *"commits/"*) s="${all##*commits/}"; s="${s%% *}"; jq -e --arg s "$s" '.[$s]? // empty' <<<"$commits" ;; | |
| *) echo "fake gh: unhandled: $all" >&2; exit 64 ;; | |
| esac | |
| FAKE | |
| chmod +x "$RUNNER_TEMP/rsrbin/gh" | |
| - name: three sources, the glob, web-flow, and the unsigned answer | |
| run: | | |
| set -euo pipefail | |
| run_gate() { # attestation-glob accept-commit accept-web-flow | |
| : > "$RUNNER_TEMP/rsr-out" | |
| PATH="$RUNNER_TEMP/rsrbin:$PATH" GITHUB_REPOSITORY=selftest/gate \ | |
| GITHUB_OUTPUT="$RUNNER_TEMP/rsr-out" \ | |
| INPUT_VERSION=9.9.0 INPUT_TAG='' INPUT_ATTESTATION_TAGS="$1" \ | |
| INPUT_ACCEPT_RELEASE_TAG=true INPUT_ACCEPT_ATTESTATION_TAG=true \ | |
| INPUT_ACCEPT_SIGNED_COMMIT="$2" INPUT_ACCEPT_WEB_FLOW="$3" \ | |
| bash .github/actions/require-signed-release/require-signed-release.sh | |
| } | |
| expect() { grep -qx "$1" "$RUNNER_TEMP/rsr-out"; } | |
| export FAKE_REFS='{"v9.9.0":{"object":{"sha":"aaa","type":"tag"}}}' | |
| export FAKE_TAG_OBJECTS='{"aaa":{"object":{"sha":"ccc"},"verification":{"verified":true}},"bbb":{"object":{"sha":"ccc"},"verification":{"verified":true}},"eee":{"object":{"sha":"ddd"},"verification":{"verified":true}}}' | |
| export FAKE_TAG_LIST='[]' FAKE_COMMITS='{}' | |
| run_gate '*' false false >/dev/null | |
| expect 'signed=true' && expect 'source=release-tag' | |
| # an unverified release tag falls through to the companion | |
| export FAKE_TAG_OBJECTS='{"aaa":{"object":{"sha":"ccc"},"verification":{"verified":false}},"bbb":{"object":{"sha":"ccc"},"verification":{"verified":true}},"eee":{"object":{"sha":"ddd"},"verification":{"verified":true}}}' | |
| export FAKE_TAG_LIST='[{"ref":"refs/tags/v9.9.0","object":{"sha":"aaa","type":"tag"}},{"ref":"refs/tags/attest1","object":{"sha":"bbb","type":"tag"}}]' | |
| run_gate '*' false false >/dev/null | |
| expect 'signed=true' && expect 'source=attestation-tag' && expect 'attestation=attest1' | |
| # glob narrowing and a different-commit companion do not count | |
| run_gate 'v*-sig' false false >/dev/null; expect 'signed=false' | |
| export FAKE_TAG_LIST='[{"ref":"refs/tags/attest1","object":{"sha":"eee","type":"tag"}}]' | |
| run_gate '*' false false >/dev/null; expect 'signed=false' | |
| # commit signatures: opt-in, and web-flow only when accepted | |
| export FAKE_TAG_LIST='[]' | |
| export FAKE_COMMITS='{"ccc":{"commit":{"verification":{"verified":true}},"committer":{"login":"selftest"}}}' | |
| run_gate '*' false false >/dev/null; expect 'signed=false' | |
| run_gate '*' true false >/dev/null; expect 'signed=true' && expect 'source=commit' | |
| export FAKE_COMMITS='{"ccc":{"commit":{"verification":{"verified":true}},"committer":{"login":"web-flow"}}}' | |
| run_gate '*' true false >/dev/null; expect 'signed=false' | |
| run_gate '*' true true >/dev/null; expect 'signed=true' | |
| echo "✓ release tag, companion, glob, different-commit, opt-in commit, and web-flow roads all answer correctly" | |
| # The signature-triggered shape: the pushed companion names the release | |
| # by commit, so the job needs no string surgery — and never acts on a draft. | |
| - name: the release derives from a pushed attestation tag | |
| run: | | |
| set -euo pipefail | |
| run_attest() { # attestation-tag | |
| : > "$RUNNER_TEMP/rsr-out" | |
| PATH="$RUNNER_TEMP/rsrbin:$PATH" GITHUB_REPOSITORY=selftest/gate \ | |
| GITHUB_OUTPUT="$RUNNER_TEMP/rsr-out" \ | |
| INPUT_VERSION='' INPUT_TAG='' INPUT_ATTESTATION_TAGS='*' \ | |
| INPUT_ACCEPT_RELEASE_TAG=true INPUT_ACCEPT_ATTESTATION_TAG=true \ | |
| INPUT_ACCEPT_SIGNED_COMMIT=false INPUT_ACCEPT_WEB_FLOW=false \ | |
| INPUT_ATTESTATION_TAG="$1" INPUT_REQUIRE_PUBLISHED=true \ | |
| bash .github/actions/require-signed-release/require-signed-release.sh | |
| } | |
| expect() { grep -qx "$1" "$RUNNER_TEMP/rsr-out"; } | |
| # v9.9.0 (published) and its signed companion share a commit with the | |
| # moving major v9, which has no release and must be ignored. | |
| export FAKE_REFS='{"v9.9.0-sig":{"object":{"sha":"bbb","type":"tag"}}}' | |
| export FAKE_TAG_OBJECTS='{"aaa":{"object":{"sha":"ccc"},"verification":{"verified":false}},"bbb":{"object":{"sha":"ccc"},"verification":{"verified":true}},"ddd":{"object":{"sha":"ccc"},"verification":{"verified":false}}}' | |
| export FAKE_TAG_LIST='[{"ref":"refs/tags/v9.9.0","object":{"sha":"aaa","type":"tag"}},{"ref":"refs/tags/v9.9.0-sig","object":{"sha":"bbb","type":"tag"}},{"ref":"refs/tags/v9","object":{"sha":"ddd","type":"tag"}}]' | |
| export FAKE_COMMITS='{}' | |
| export FAKE_RELEASES='{"v9.9.0":"published"}' | |
| run_attest v9.9.0-sig >/dev/null | |
| expect 'signed=true' && expect 'source=attestation-tag' | |
| expect 'release-tag=v9.9.0' && expect 'version=9.9.0' && expect 'commit=ccc' | |
| export FAKE_RELEASES='{"v9.9.0":"draft"}' | |
| if run_attest v9.9.0-sig > "$RUNNER_TEMP/rsr-draft.log" 2>&1; then | |
| echo "::error::a draft release was accepted"; exit 1 | |
| fi | |
| grep -q 'still a draft' "$RUNNER_TEMP/rsr-draft.log" | |
| export FAKE_RELEASES='{}' | |
| if run_attest v9.9.0-sig > "$RUNNER_TEMP/rsr-none.log" 2>&1; then | |
| echo "::error::a commit with no release was accepted"; exit 1 | |
| fi | |
| grep -q 'no published release' "$RUNNER_TEMP/rsr-none.log" | |
| echo "✓ the companion names its release by commit; drafts and release-less commits are refused" | |
| 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: "CARGO_HOME|RUSTUP_HOME|CARGO_TARGET_DIR|CICD_PROBE" | |
| env: | | |
| CICD_EXPECT=absent | |
| - name: publish-dry-run (networked prep, sealed verify-build) | |
| uses: ./.github/actions/publish-dry-run | |
| with: | |
| image: rust-ci:selftest | |
| working-directory: fixtures/sample-crate | |
| expected-version: "0.1.0" | |
| # cargo-install + cargo-use: install a real CLI crate into a SHARED cache in | |
| # one sealed container, then run it from a SEPARATE sealed, offline container. | |
| # Proves an installed tool persists in the host-mounted CARGO_HOME and resolves | |
| # via the prepended bin dir — including the `cargo <subcommand>` form. npm-utils | |
| # is pure-Rust (no openssl) and gates its binaries behind the `cli` feature, so | |
| # this also exercises the free-form `args` pass-through. | |
| - name: cargo-install (sealed) a CLI crate into the shared cache | |
| uses: ./.github/actions/cargo-install | |
| with: | |
| image: rust-ci:selftest | |
| tool: npm-utils | |
| args: "--features cli" | |
| locked: "false" # probe tests the action plumbing, not --locked semantics | |
| cargo-cache: .cargo-tools | |
| - name: both installed binaries landed in the shared host dir | |
| run: test -x .cargo-tools/bin/npm-utils && test -x .cargo-tools/bin/cargo-npm-utils | |
| - name: cargo-use (sealed, offline) runs the installed cargo subcommand | |
| uses: ./.github/actions/cargo-use | |
| with: | |
| image: rust-ci:selftest | |
| cargo-cache: .cargo-tools | |
| offline: "true" | |
| args: "cargo npm-utils --help" | |
| # Negative control: cargo-use offline really seals the network — a tool that | |
| # reaches out must fail (proves the action wires OFFLINE -> --network=none). | |
| - name: cargo-use offline blocks egress (negative control) | |
| id: use_offline_egress | |
| continue-on-error: true | |
| uses: ./.github/actions/cargo-use | |
| with: | |
| image: rust-ci:selftest | |
| cargo-cache: .cargo-tools | |
| offline: "true" | |
| args: "curl -sSf --max-time 5 https://crates.io" | |
| - name: assert cargo-use sealed the network | |
| run: | | |
| if [ "${{ steps.use_offline_egress.outcome }}" != "failure" ]; then | |
| echo "::error::cargo-use offline did not block network egress"; exit 1 | |
| fi | |
| echo "✓ cargo-use offline sealed the network" | |
| # build-image `msrv` sentinel: build at the fixture's DECLARED rust-version, read | |
| # from Cargo.toml (no hand-managed version), so a consumer can run the whole Docker | |
| # pipeline on the support floor. The fixture pins 1.82. | |
| - name: build-image resolves the msrv sentinel from Cargo.toml | |
| uses: ./.github/actions/build-image | |
| with: | |
| rust-version: msrv | |
| working-directory: fixtures/sample-crate | |
| tag: rust-ci:bi-msrv | |
| - name: the sentinel image IS the declared MSRV (1.82) | |
| run: | | |
| v="$(docker run --rm rust-ci:bi-msrv rustc --version)" | |
| echo "$v" | |
| case "$v" in | |
| *" 1.82"*) echo "✓ sentinel image is rust 1.82" ;; | |
| *) echo "::error::sentinel image is not rust 1.82: $v"; exit 1 ;; | |
| esac | |
| # Negative control: a non-numeric Cargo.toml rust-version is rejected by the shared | |
| # resolver before it can become a docker tag — proving build-image wires the | |
| # validation into the sentinel path (the smuggled command must not run). | |
| - name: craft a bad-MSRV manifest (for the negative control) | |
| run: | | |
| mkdir -p "$RUNNER_TEMP/bad-msrv" | |
| printf '[package]\nname = "bad"\nversion = "0.0.0"\nrust-version = "1.0; touch pwned"\n' \ | |
| > "$RUNNER_TEMP/bad-msrv/Cargo.toml" | |
| - name: build-image rejects a non-numeric declared rust-version (negative control) | |
| id: bi_bad | |
| continue-on-error: true | |
| uses: ./.github/actions/build-image | |
| with: | |
| rust-version: msrv | |
| working-directory: ${{ runner.temp }}/bad-msrv | |
| tag: rust-ci:bi-bad | |
| - name: assert the bad declared rust-version was rejected | |
| run: | | |
| if [ "${{ steps.bi_bad.outcome }}" != "failure" ]; then | |
| echo "::error::build-image accepted a non-numeric rust-version"; exit 1 | |
| fi | |
| test ! -e "$RUNNER_TEMP/bad-msrv/pwned" | |
| echo "✓ build-image rejected a non-numeric declared rust-version" | |
| # msrv: build an image at the fixture's DECLARED rust-version and cargo check there. | |
| # Exercises the Cargo.toml extraction, the image-at-MSRV build, and the sealed check. | |
| # locked: true takes the committed-lockfile path — the fixture pins one. | |
| - name: msrv (image at the declared MSRV, sealed check) | |
| uses: ./.github/actions/msrv | |
| with: | |
| working-directory: fixtures/sample-crate | |
| package: cicd-rust-selftest-fixture | |
| locked: "true" | |
| # Negative control: a non-numeric rust-version is rejected before it can reach a docker | |
| # tag or shell (proves the input validation; the smuggled command must not run). | |
| - name: msrv rejects a non-numeric rust-version (negative control) | |
| id: msrv_bad | |
| continue-on-error: true | |
| uses: ./.github/actions/msrv | |
| with: | |
| working-directory: fixtures/sample-crate | |
| rust-version: "latest; touch pwned" | |
| - name: assert the bad rust-version was rejected | |
| run: | | |
| if [ "${{ steps.msrv_bad.outcome }}" != "failure" ]; then | |
| echo "::error::msrv accepted a non-numeric rust-version"; exit 1 | |
| fi | |
| test ! -e fixtures/sample-crate/pwned | |
| echo "✓ msrv rejected a non-numeric rust-version" | |
| # A crate with NO committed lockfile must still work: the source is mounted read-only for the | |
| # sealed check, so the action resolves the lock up front rather than failing to write it there. | |
| - name: stage a copy of the fixture with no Cargo.lock | |
| run: | | |
| rm -rf "$RUNNER_TEMP/fixture-nolock" | |
| mkdir -p "$RUNNER_TEMP/fixture-nolock" | |
| rsync -a --exclude='target*' --exclude='Cargo.lock' fixtures/sample-crate/ "$RUNNER_TEMP/fixture-nolock/" | |
| test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock" | |
| # Negative control: locked: true demands a committed lockfile, so a crate without one fails | |
| # with a clear up-front error — not the cryptic read-only-filesystem failure of writing it in-container. | |
| - name: msrv (locked true) rejects a crate with no committed lockfile | |
| id: msrv_nolock_locked | |
| continue-on-error: true | |
| uses: ./.github/actions/msrv | |
| with: | |
| working-directory: ${{ runner.temp }}/fixture-nolock | |
| package: cicd-rust-selftest-fixture | |
| locked: "true" | |
| - name: assert the missing lockfile was rejected | |
| run: | | |
| if [ "${{ steps.msrv_nolock_locked.outcome }}" != "failure" ]; then | |
| echo "::error::msrv locked:true accepted a crate with no Cargo.lock"; exit 1 | |
| fi | |
| test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock" | |
| echo "✓ msrv locked:true requires a committed lockfile" | |
| # Default (locked: false): resolve a fresh lockfile at the MSRV up front, then check it sealed. | |
| - name: msrv (locked false) resolves a lockfile when none is committed | |
| uses: ./.github/actions/msrv | |
| with: | |
| working-directory: ${{ runner.temp }}/fixture-nolock | |
| package: cicd-rust-selftest-fixture | |
| # The resolution happens in a disposable copy, so the lockless checkout must STAY lockless — | |
| # the action may not leave an untracked Cargo.lock behind. | |
| - name: assert the lockless checkout stayed lockless | |
| run: | | |
| test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock" | |
| echo "✓ msrv locked:false left no Cargo.lock behind" | |
| # locked: false must not rewrite a committed lockfile either: fresh resolution happens in | |
| # the disposable copy while the checkout's Cargo.lock stays byte-identical. | |
| - name: snapshot the committed lockfile | |
| run: cp fixtures/sample-crate/Cargo.lock "$RUNNER_TEMP/Cargo.lock.before" | |
| - name: msrv (locked false) resolves fresh without touching the committed lockfile | |
| uses: ./.github/actions/msrv | |
| with: | |
| working-directory: fixtures/sample-crate | |
| package: cicd-rust-selftest-fixture | |
| - name: assert the committed lockfile is byte-identical | |
| run: | | |
| cmp "$RUNNER_TEMP/Cargo.lock.before" fixtures/sample-crate/Cargo.lock | |
| echo "✓ msrv locked:false preserved the committed Cargo.lock byte-for-byte" | |
| # 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 | |
| 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 | |
| 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" |