Skip to content

fix(require-signed-release): plain-text the ref-name example so the a… #118

fix(require-signed-release): plain-text the ref-name example so the a…

fix(require-signed-release): plain-text the ref-name example so the a… #118

Workflow file for this run

name: selftest
# Exercise every composite action end-to-end against the fixture crate, so the
# actions are verified before any consumer relies on them.
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
selftest:
name: exercise the composite actions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: install-toolchain
uses: ./.github/actions/install-toolchain
with:
components: rustfmt clippy
# rust-cache: warms the cargo cache + sets CARGO_INCREMENTAL=0 for the build steps below.
- name: rust-cache (fixture)
uses: ./.github/actions/rust-cache
with:
prefix: fixture
cache-target: "true"
target-dir: fixtures/sample-crate/target
lockfiles: fixtures/sample-crate/Cargo.lock
# Prove the env export crosses into a later step (the build inherits incremental=off).
- name: rust-cache exported CARGO_INCREMENTAL=0
run: test "$CARGO_INCREMENTAL" = "0"
- name: lint-and-test (fixture)
uses: ./.github/actions/lint-and-test
with:
working-directory: fixtures/sample-crate
# 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 — 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
ls "$deps" | grep -q '^liblibc-' || {
echo "::error::dependency artifacts missing after prune"; exit 1; }
if ls "$deps" | grep -q '^cicd_rust_selftest_fixture-'; 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@v6
# 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 pre-release tag is a baseline of its own
run: |
git -C "$RUNNER_TEMP/rc" tag -d v1.0.0
sed -i 's/^version = "1.0.0"/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
echo 'A release with `code`, **bold**, an `@import` token, and a provider-* glob.'
echo
echo '### Added'
echo
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
grep -q '`@import`' "$out" || { echo "::error::code span dropped — @tokens would autolink as @mentions"; exit 1; }
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@v6
# 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"
- name: stage an existing release branch
run: |
git -C "$RUNNER_TEMP/cut-release" checkout -q -- CHANGELOG.md
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@v6
# 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"
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
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@v6
- 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"
# 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"
cargo-cache: .cargo-cache-online
target-dir: target-online
# Exclusion wins: CICD_PROBE matches env-include but is also in
# env-exclude, so it must NOT cross (CICD_EXPECT=absent asserts that).
env-include: "CICD_.*"
env-exclude: "CARGO_HOME|RUSTUP_HOME|CARGO_TARGET_DIR|CICD_PROBE"
env: |
CICD_EXPECT=absent
- name: publish-dry-run (networked prep, sealed verify-build)
uses: ./.github/actions/publish-dry-run
with:
image: rust-ci:selftest
working-directory: fixtures/sample-crate
expected-version: "0.1.0"
# cargo-install + cargo-use: install a real CLI crate into a SHARED cache in
# one sealed container, then run it from a SEPARATE sealed, offline container.
# Proves an installed tool persists in the host-mounted CARGO_HOME and resolves
# via the prepended bin dir — including the `cargo <subcommand>` form. npm-utils
# is pure-Rust (no openssl) and gates its binaries behind the `cli` feature, so
# this also exercises the free-form `args` pass-through.
- name: cargo-install (sealed) a CLI crate into the shared cache
uses: ./.github/actions/cargo-install
with:
image: rust-ci:selftest
tool: npm-utils
args: "--features cli"
locked: "false" # probe tests the action plumbing, not --locked semantics
cargo-cache: .cargo-tools
- name: both installed binaries landed in the shared host dir
run: test -x .cargo-tools/bin/npm-utils && test -x .cargo-tools/bin/cargo-npm-utils
- name: cargo-use (sealed, offline) runs the installed cargo subcommand
uses: ./.github/actions/cargo-use
with:
image: rust-ci:selftest
cargo-cache: .cargo-tools
offline: "true"
args: "cargo npm-utils --help"
# Negative control: cargo-use offline really seals the network — a tool that
# reaches out must fail (proves the action wires OFFLINE -> --network=none).
- name: cargo-use offline blocks egress (negative control)
id: use_offline_egress
continue-on-error: true
uses: ./.github/actions/cargo-use
with:
image: rust-ci:selftest
cargo-cache: .cargo-tools
offline: "true"
args: "curl -sSf --max-time 5 https://crates.io"
- name: assert cargo-use sealed the network
run: |
if [ "${{ steps.use_offline_egress.outcome }}" != "failure" ]; then
echo "::error::cargo-use offline did not block network egress"; exit 1
fi
echo "✓ cargo-use offline sealed the network"
# build-image `msrv` sentinel: build at the fixture's DECLARED rust-version, read
# from Cargo.toml (no hand-managed version), so a consumer can run the whole Docker
# pipeline on the support floor. The fixture pins 1.82.
- name: build-image resolves the msrv sentinel from Cargo.toml
uses: ./.github/actions/build-image
with:
rust-version: msrv
working-directory: fixtures/sample-crate
tag: rust-ci:bi-msrv
- name: the sentinel image IS the declared MSRV (1.82)
run: |
v="$(docker run --rm rust-ci:bi-msrv rustc --version)"
echo "$v"
case "$v" in
*" 1.82"*) echo "✓ sentinel image is rust 1.82" ;;
*) echo "::error::sentinel image is not rust 1.82: $v"; exit 1 ;;
esac
# Negative control: a non-numeric Cargo.toml rust-version is rejected by the shared
# resolver before it can become a docker tag — proving build-image wires the
# validation into the sentinel path (the smuggled command must not run).
- name: craft a bad-MSRV manifest (for the negative control)
run: |
mkdir -p "$RUNNER_TEMP/bad-msrv"
printf '[package]\nname = "bad"\nversion = "0.0.0"\nrust-version = "1.0; touch pwned"\n' \
> "$RUNNER_TEMP/bad-msrv/Cargo.toml"
- name: build-image rejects a non-numeric declared rust-version (negative control)
id: bi_bad
continue-on-error: true
uses: ./.github/actions/build-image
with:
rust-version: msrv
working-directory: ${{ runner.temp }}/bad-msrv
tag: rust-ci:bi-bad
- name: assert the bad declared rust-version was rejected
run: |
if [ "${{ steps.bi_bad.outcome }}" != "failure" ]; then
echo "::error::build-image accepted a non-numeric rust-version"; exit 1
fi
test ! -e "$RUNNER_TEMP/bad-msrv/pwned"
echo "✓ build-image rejected a non-numeric declared rust-version"
# msrv: build an image at the fixture's DECLARED rust-version and cargo check there.
# Exercises the Cargo.toml extraction, the image-at-MSRV build, and the sealed check.
# locked: true takes the committed-lockfile path — the fixture pins one.
- name: msrv (image at the declared MSRV, sealed check)
uses: ./.github/actions/msrv
with:
working-directory: fixtures/sample-crate
package: cicd-rust-selftest-fixture
locked: "true"
# Negative control: a non-numeric rust-version is rejected before it can reach a docker
# tag or shell (proves the input validation; the smuggled command must not run).
- name: msrv rejects a non-numeric rust-version (negative control)
id: msrv_bad
continue-on-error: true
uses: ./.github/actions/msrv
with:
working-directory: fixtures/sample-crate
rust-version: "latest; touch pwned"
- name: assert the bad rust-version was rejected
run: |
if [ "${{ steps.msrv_bad.outcome }}" != "failure" ]; then
echo "::error::msrv accepted a non-numeric rust-version"; exit 1
fi
test ! -e fixtures/sample-crate/pwned
echo "✓ msrv rejected a non-numeric rust-version"
# A crate with NO committed lockfile must still work: the source is mounted read-only for the
# sealed check, so the action resolves the lock up front rather than failing to write it there.
- name: stage a copy of the fixture with no Cargo.lock
run: |
rm -rf "$RUNNER_TEMP/fixture-nolock"
mkdir -p "$RUNNER_TEMP/fixture-nolock"
rsync -a --exclude='target*' --exclude='Cargo.lock' fixtures/sample-crate/ "$RUNNER_TEMP/fixture-nolock/"
test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock"
# Negative control: locked: true demands a committed lockfile, so a crate without one fails
# with a clear up-front error — not the cryptic read-only-filesystem failure of writing it in-container.
- name: msrv (locked true) rejects a crate with no committed lockfile
id: msrv_nolock_locked
continue-on-error: true
uses: ./.github/actions/msrv
with:
working-directory: ${{ runner.temp }}/fixture-nolock
package: cicd-rust-selftest-fixture
locked: "true"
- name: assert the missing lockfile was rejected
run: |
if [ "${{ steps.msrv_nolock_locked.outcome }}" != "failure" ]; then
echo "::error::msrv locked:true accepted a crate with no Cargo.lock"; exit 1
fi
test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock"
echo "✓ msrv locked:true requires a committed lockfile"
# Default (locked: false): resolve a fresh lockfile at the MSRV up front, then check it sealed.
- name: msrv (locked false) resolves a lockfile when none is committed
uses: ./.github/actions/msrv
with:
working-directory: ${{ runner.temp }}/fixture-nolock
package: cicd-rust-selftest-fixture
# The resolution happens in a disposable copy, so the lockless checkout must STAY lockless —
# the action may not leave an untracked Cargo.lock behind.
- name: assert the lockless checkout stayed lockless
run: |
test ! -e "$RUNNER_TEMP/fixture-nolock/Cargo.lock"
echo "✓ msrv locked:false left no Cargo.lock behind"
# locked: false must not rewrite a committed lockfile either: fresh resolution happens in
# the disposable copy while the checkout's Cargo.lock stays byte-identical.
- name: snapshot the committed lockfile
run: cp fixtures/sample-crate/Cargo.lock "$RUNNER_TEMP/Cargo.lock.before"
- name: msrv (locked false) resolves fresh without touching the committed lockfile
uses: ./.github/actions/msrv
with:
working-directory: fixtures/sample-crate
package: cicd-rust-selftest-fixture
- name: assert the committed lockfile is byte-identical
run: |
cmp "$RUNNER_TEMP/Cargo.lock.before" fixtures/sample-crate/Cargo.lock
echo "✓ msrv locked:false preserved the committed Cargo.lock byte-for-byte"
# 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@v6
- uses: ./.github/actions/install-toolchain
with:
components: clippy
- name: toolchain is installed + on PATH
shell: bash
run: rustc --version && cargo --version && cargo clippy --version
- name: build the fixture
shell: bash
working-directory: fixtures/sample-crate
run: cargo build --locked
# rust-cache must OBEY an explicit CARGO_INCREMENTAL (not force it off): set it at job level and
# confirm the action leaves it untouched for the build steps that would follow.
selftest-cache-passthrough:
name: rust-cache obeys CARGO_INCREMENTAL
runs-on: ubuntu-latest
env:
CARGO_INCREMENTAL: "1"
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/rust-cache
with:
prefix: passthrough
- name: explicit CARGO_INCREMENTAL is preserved
run: test "$CARGO_INCREMENTAL" = "1"
# 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@v6
- 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@v6
- 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@v6
- uses: ./.github/actions/rust-cache
with:
prefix: nosave-${{ github.run_id }}
save: "false"
selftest-cache-no-save-assert:
name: rust-cache save:false left no entry
needs: selftest-cache-no-save
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: no cache entry exists for the nosave prefix
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
# 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; }
echo 0 > "$count"
retry_transient flaky >/dev/null
[ "$(cat "$count")" = "3" ] || { echo "::error::a registry 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; }