Skip to content

P13: First-party CI Action (rebuilt from scratch against current main) #8

P13: First-party CI Action (rebuilt from scratch against current main)

P13: First-party CI Action (rebuilt from scratch against current main) #8

name: Action smoke test
# Exercises the repo-root `action.yml` composite action the same way an
# external consumer would (`uses: juntaki/mutantkit@<ref>`), except pointed
# at this checkout (`uses: ./`) so a change to action.yml is validated
# before it ever reaches a tag.
#
# Two genuinely different things are being proven here, and they are kept
# in separate jobs on purpose (P13 review, item 3):
#
# - "install mode against a real release" (the `install-mode-*` jobs) —
# depends on a real, already-published GitHub Release existing to
# download. `release.yml`'s own `clean-machine-e2e` job already proves
# the underlying tarball/SHA256SUMS/PATH recipe at release time; these
# jobs prove action.yml wraps that recipe correctly, including
# attestation verification and version-pin resolution.
#
# - "CI-mode orchestration" (every other job) — this cannot be proven
# against a published release: the CLI surface `mode: ci` depends on
# (`gate --json`, `run --also-report`) postdates the latest release at
# the time this test suite was written (v0.2.0 has neither — see
# `Scripts/action/preflight-capabilities.sh`'s own doc comment). Every
# `ci-mode-*`/`build-from-source` job below instead builds `mutantkit`
# straight from this exact PR HEAD and feeds it to action.yml via
# `internal-test-binary-dir` (install.sh's own test-only seam — never a
# documented, supported input), so what is actually exercised is this
# PR's real orchestration script against this PR's real CLI, not a
# stand-in for either.
#
# `pull_request` (path-filtered to this Action's own surface) is what makes
# this required evidence for a P13 PR rather than decoration: a broken
# action.yml/orchestration script on a PR that touches exactly these files
# must not be able to hide behind a green `ci.yml` that never invokes either
# one.
on:
workflow_dispatch:
schedule:
# Weekly, so a change on the GitHub Actions platform side (runner image,
# curl/shasum/tar/gh-cli behavior) that silently breaks the action is
# caught without anyone having to remember to run it by hand.
- cron: "0 6 * * 1"
pull_request:
paths:
- "action.yml"
- "Scripts/action/**"
- ".github/workflows/action-smoke-test.yml"
jobs:
# ---------------------------------------------------------------------
# mode: install, against a real published release.
# ---------------------------------------------------------------------
install-mode:
name: "mode: install (${{ matrix.version }})"
runs-on: macos-15
strategy:
fail-fast: false
matrix:
version: ["latest"]
steps:
- uses: actions/checkout@v4
- name: Install mutantkit via this repo's own action.yml
id: mutantkit
uses: ./
with:
version: ${{ matrix.version }}
- name: outputs.version is a single non-empty line matching a fresh `mutantkit --version`
shell: bash
run: |
set -euo pipefail
command -v mutantkit
output_version="${{ steps.mutantkit.outputs.version }}"
if [ -z "$output_version" ]; then
echo "::error::outputs.version was empty" >&2
exit 1
fi
non_empty_line_count="$(printf '%s\n' "$output_version" | grep -c .)"
if [ "$non_empty_line_count" != "1" ]; then
echo "::error::outputs.version must be exactly one non-empty line, got: $output_version" >&2
exit 1
fi
fresh="$(mutantkit --version | head -n1)"
if [ "$fresh" != "$output_version" ]; then
echo "::error::outputs.version ('$output_version') does not match a fresh 'mutantkit --version' first line ('$fresh')" >&2
exit 1
fi
- name: "mode: install never starts a mutation campaign (no project present)"
shell: bash
run: |
set -euo pipefail
# `doctor` on an empty directory must fail *honestly* — a specific,
# actionable diagnostic and MutantKitExit.operationalError — never
# crash, and (this is the point of this job) never get invoked by
# `mode: install` at all in the first place. `|| true` on `doctor`
# was an earlier version of this test's own mistake: it proved
# nothing beyond "the process exited," which a crash also does.
#
# Deliberately the *text* path, not `--json`: `mode: install` (this
# job's matrix) can install any published release, and `doctor
# --json` is itself part of the same P9-P12 CLI work `gate --json`/
# `run --also-report` came from — it does not exist on `v0.2.0`
# (confirmed: `mutantkit doctor --json` there exits 64,
# ArgumentParser's own "unknown option," not this tool's
# `operationalError`). The text path's exit code and prose have
# been stable since this tool's first commit.
#
# `--project-root` points at a fresh, genuinely-empty temp
# directory, not CWD: this job's own workspace is the checked-out
# mutantkit repo itself (needed for `uses: ./`), which has a very
# real `Package.swift` at its root — running `doctor` against CWD
# would detect *that* project and, having a real project, could
# legitimately report ready, proving nothing about a "no project"
# environment.
empty_dir="$RUNNER_TEMP/no-project-here"
mkdir -p "$empty_dir"
set +e
doctor_output="$(mutantkit doctor --project-root "$empty_dir" 2>&1)"
doctor_exit=$?
set -e
echo "$doctor_output"
if [ "$doctor_exit" != "1" ]; then
echo "::error::expected mutantkit doctor to exit 1 (MutantKitExit.operationalError) on an empty directory, got $doctor_exit" >&2
exit 1
fi
echo "$doctor_output" | grep -q 'No Swift project found' \
|| { echo "::error::expected doctor's own 'No Swift project found' diagnosis"; exit 1; }
echo "$doctor_output" | grep -q 'Not ready' \
|| { echo "::error::expected doctor's own 'Not ready' verdict line"; exit 1; }
# The actual proof this job exists for: mode: install must never
# itself have run doctor/plan/run — no artifact of a campaign
# should exist in this empty workspace.
for f in plan.json report.json gate-result.json .mutantkit; do
if [ -e "$f" ]; then
echo "::error::mode: install left behind '$f' — it must never run doctor/plan/run/gate itself" >&2
exit 1
fi
done
mode-validation:
name: "Unknown mode fails closed, before any download"
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: "mode: not-a-real-mode"
id: bogus-mode
continue-on-error: true
uses: ./
with:
mode: not-a-real-mode
- name: Assert the step actually failed
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.bogus-mode.outcome }}" != "failure" ]; then
echo "::error::expected mode: not-a-real-mode to fail closed, got outcome '${{ steps.bogus-mode.outcome }}'" >&2
exit 1
fi
incompatible-cli-fails-closed:
name: "mode: ci against a pre-P13 release fails at preflight, not mid-campaign"
runs-on: macos-15
steps:
- name: Checkout mutantkit (action.yml)
uses: actions/checkout@v4
with:
path: mutantkit-src
- name: Stage a real Swift package as the project under test
shell: bash
run: |
set -euo pipefail
cp -R mutantkit-src/Fixtures/SwiftPackageMacOS/. .
cat > mutantkit.yml <<'YAML'
version: 1
project:
kind: swiftPackageMacOS
sources:
include: [Sources/**]
operators:
profile: default
execution:
strategy: isolated
workers: 2
reports: [console, json]
YAML
# v0.2.0 is real: the latest published release at the time P13 was
# written, and confirmed (Scripts/action/preflight-capabilities.sh's
# own doc comment) to have neither `gate --json` nor `run
# --also-report`. `mode: ci` against it must refuse to even start
# doctor, not fail confusingly deep into a real mutation campaign.
- name: "mode: ci, version: v0.2.0"
id: old-cli
continue-on-error: true
uses: ./mutantkit-src
with:
mode: ci
version: v0.2.0
- name: Assert it failed before doctor/plan ever ran
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.old-cli.outcome }}" != "failure" ]; then
echo "::error::expected mode: ci with an incompatible CLI version to fail, got outcome '${{ steps.old-cli.outcome }}'" >&2
exit 1
fi
for f in plan.json report.json gate-result.json; do
if [ -e "$f" ]; then
echo "::error::'$f' exists — the incompatible-CLI preflight should have failed before plan/run ever produced output" >&2
exit 1
fi
done
# ---------------------------------------------------------------------
# mode: ci, against mutantkit built from this exact PR HEAD.
# ---------------------------------------------------------------------
build-from-source:
name: Build mutantkit from PR HEAD (shared by every mode:ci job below)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: swift build -c release --product mutantkit
- name: Stage the built binary for other jobs
shell: bash
run: |
set -euo pipefail
mkdir -p dist-bin
cp .build/release/mutantkit dist-bin/mutantkit
- uses: actions/upload-artifact@v4
with:
name: mutantkit-from-source
path: dist-bin/mutantkit
retention-days: 1
ci-mode-happy-path:
name: "mode: ci happy path — doctor/plan/run/gate/summary/artifacts, reporter preservation"
needs: build-from-source
runs-on: macos-15
steps:
- name: Checkout mutantkit (action.yml)
uses: actions/checkout@v4
with:
path: mutantkit-src
- uses: actions/download-artifact@v4
with:
name: mutantkit-from-source
path: test-binary
- run: chmod +x test-binary/mutantkit
shell: bash
# `Fixtures/SwiftPackageMacOS`'s mutants are pinned by this repo's own
# `SwiftPackageMacOSAcceptanceTests`: 3 killed, 4 survived, zero
# integrity violations under `execution.strategy: isolated` — not a
# full kill. No `qualityGate:` is configured below, so `gate`'s
# `passed: true` here asserts "integrity held and a real score was
# computed" (see `QualityGate.evaluate`'s own fail-closed
# `scoreUnavailable` path for what a broken pipeline would show
# instead), not "every mutant died" — the deterministic
# threshold-failure path is `ci-mode-quality-gate-failure` below.
# `reports: [sonar]` is deliberately a kind this action never itself
# requests (P13 review, item 4/reporter preservation): its survival
# alongside the four CI-required kinds is what proves `--also-report`
# added to the config's own reports instead of replacing them.
- name: Stage the fixture project
shell: bash
run: |
set -euo pipefail
cp -R mutantkit-src/Fixtures/SwiftPackageMacOS/. .
cat > mutantkit.yml <<'YAML'
version: 1
project:
kind: swiftPackageMacOS
sources:
include: [Sources/**]
operators:
profile: default
execution:
strategy: isolated
workers: 2
reports: [sonar]
YAML
- name: "Run mutantkit via action.yml (mode: ci)"
id: mutantkit
uses: ./mutantkit-src
with:
mode: ci
internal-test-binary-dir: ${{ github.workspace }}/test-binary
artifact-name: mutantkit-report-happy-path
- name: Assert the run actually produced real, trustworthy output
if: always()
shell: bash
run: |
set -euo pipefail
echo "::group::report.json"
test -s report.json || { echo "::error::report.json is missing or empty"; exit 1; }
jq -e '.score.killed != null and .score.survived != null' report.json >/dev/null \
|| { echo "::error::report.json has no mutation score"; cat report.json; exit 1; }
jq -e '.integrity.violations | length == 0' report.json >/dev/null \
|| { echo "::error::report.json recorded an integrity violation"; jq '.integrity' report.json; exit 1; }
jq '{score, integrityViolations: .integrity.violations}' report.json
echo "::endgroup::"
echo "::group::gate-result.json"
test -s gate-result.json || { echo "::error::gate-result.json is missing or empty"; exit 1; }
jq -e 'has("passed") and has("violations")' gate-result.json >/dev/null \
|| { echo "::error::gate-result.json is not a QualityGateResult"; cat gate-result.json; exit 1; }
cat gate-result.json
[ "$(jq -r '.passed' gate-result.json)" = "true" ] \
|| { echo "::error::expected gate to pass against this fixture's known-good baseline"; exit 1; }
echo "::endgroup::"
echo "::group::job summary"
# NOT `test -s "$GITHUB_STEP_SUMMARY"`: that env var names a fresh
# temp file *per step* (GitHub concatenates every step's own file
# into one Job Summary in the UI afterward) — this step's own copy
# of it was never written to, only the composite action's internal
# "Write job summary" step's own copy was, and that file is gone
# once its step ends. What IS still on disk and shared across
# steps is gate-result.json/.mutantkit/summary.md — re-running the
# exact same summarize-gate.sh against them here re-proves the
# composite step's own rendering, against this real pipeline's
# real output, without depending on cross-step $GITHUB_STEP_SUMMARY
# sharing that GitHub Actions does not provide.
local_summary="$RUNNER_TEMP/reproduced-job-summary.md"
: > "$local_summary"
GITHUB_STEP_SUMMARY="$local_summary" mutantkit-src/Scripts/action/summarize-gate.sh gate-result.json .mutantkit/summary.md
cat "$local_summary"
grep -q ':white_check_mark: Mutation quality gate passed' "$local_summary" \
|| { echo "::error::job summary is missing the expected pass banner"; exit 1; }
grep -qi 'mutation' "$local_summary" \
|| { echo "::error::job summary is missing CISummaryReporter's own section (summary.md was not folded in)"; exit 1; }
echo "::endgroup::"
echo "::group::reporter preservation (--also-report is additive)"
test -s .mutantkit/report.html || { echo "::error::.mutantkit/report.html (--also-report) was not generated"; exit 1; }
test -s .mutantkit/summary.md || { echo "::error::.mutantkit/summary.md (--also-report) was not generated"; exit 1; }
test -s .mutantkit/sonar-issues.json \
|| { echo "::error::.mutantkit/sonar-issues.json (the project's OWN configured report) is missing — --also-report appears to have replaced reports: [sonar] instead of adding to it"; exit 1; }
echo "::endgroup::"
- name: Download the artifact action.yml uploaded, under its custom name
if: always()
uses: actions/download-artifact@v4
with:
name: mutantkit-report-happy-path
path: downloaded-artifact
- name: Assert the downloaded artifact has real, non-hidden content
if: always()
shell: bash
run: |
set -euo pipefail
for f in report.json gate-result.json report.html summary.md; do
path="downloaded-artifact/$f"
test -s "$path" || { echo "::error::uploaded artifact is missing or empty: $f"; exit 1; }
done
echo "mutantkit-report-happy-path artifact verified: $(find downloaded-artifact -type f | sort | tr '\n' ' ')"
ci-mode-quality-gate-failure:
name: "mode: ci — an unmeetable threshold fails the job, artifacts still upload"
needs: build-from-source
runs-on: macos-15
steps:
- name: Checkout mutantkit (action.yml)
uses: actions/checkout@v4
with:
path: mutantkit-src
- uses: actions/download-artifact@v4
with:
name: mutantkit-from-source
path: test-binary
- run: chmod +x test-binary/mutantkit
shell: bash
# `effectiveScore.minimum: 101` cannot be met by any report (a
# percentage cannot exceed 100) — deterministic, without needing an
# actually-uncovered mutant.
- name: Stage the fixture project with an unmeetable quality gate
shell: bash
run: |
set -euo pipefail
cp -R mutantkit-src/Fixtures/SwiftPackageMacOS/. .
cat > mutantkit.yml <<'YAML'
version: 1
project:
kind: swiftPackageMacOS
sources:
include: [Sources/**]
operators:
profile: default
execution:
strategy: isolated
workers: 2
reports: [console]
qualityGate:
effectiveScore:
minimum: 101
YAML
- name: "Run mutantkit via action.yml (mode: ci) — expected to fail"
id: mutantkit
continue-on-error: true
uses: ./mutantkit-src
with:
mode: ci
internal-test-binary-dir: ${{ github.workspace }}/test-binary
artifact-name: mutantkit-report-gate-failure
- name: Assert the job is red for the right reason
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.mutantkit.outcome }}" != "failure" ]; then
echo "::error::expected the unmeetable quality gate to fail this step, got outcome '${{ steps.mutantkit.outcome }}'" >&2
exit 1
fi
test -s gate-result.json || { echo "::error::gate-result.json is missing"; exit 1; }
[ "$(jq -r '.passed' gate-result.json)" = "false" ] \
|| { echo "::error::expected gate-result.json .passed == false"; cat gate-result.json; exit 1; }
jq -e '.violations | length > 0' gate-result.json >/dev/null \
|| { echo "::error::expected at least one violation"; exit 1; }
# NOT `$GITHUB_STEP_SUMMARY` directly: it is a fresh per-step temp
# file (see the happy-path job's own comment on this) — the
# composite action's internal "Write job summary" step wrote to
# its own copy, already gone by the time this sibling step runs.
# Re-running the real summarize-gate.sh against the real
# gate-result.json this pipeline produced re-proves the same
# rendering without relying on cross-step file sharing GitHub
# Actions does not provide.
local_summary="$RUNNER_TEMP/reproduced-job-summary.md"
: > "$local_summary"
GITHUB_STEP_SUMMARY="$local_summary" mutantkit-src/Scripts/action/summarize-gate.sh gate-result.json
cat "$local_summary"
grep -q ':x: Mutation quality gate failed' "$local_summary" \
|| { echo "::error::job summary is missing the expected failure banner"; exit 1; }
grep -q 'effectiveScore' "$local_summary" \
|| { echo "::error::job summary did not list the effectiveScore violation"; exit 1; }
- name: Artifacts must still be uploaded on a failing gate
uses: actions/download-artifact@v4
with:
name: mutantkit-report-gate-failure
path: downloaded-artifact
- shell: bash
run: |
set -euo pipefail
test -s downloaded-artifact/gate-result.json \
|| { echo "::error::gate-result.json missing from the artifact on a failing gate"; exit 1; }
ci-mode-gate-operational-failure:
name: "gate operational failure (missing/malformed report) never reads as a pass"
runs-on: macos-15
steps:
- uses: actions/checkout@v4
# Drives Scripts/action/summarize-gate.sh directly, against
# hand-crafted gate-result.json fixtures — the operational-failure and
# malformed-JSON shapes below are *not* reachable by running a real
# mutation campaign (see the script's own doc comment: they mean
# `mutantkit gate` itself never reached a verdict), so this is the
# actual failure mode the summarizer's own shape-classification exists
# to handle. `--json`'s two real shapes (QualityGateResult /
# JSONErrorEnvelope) are quoted directly from Sources/CLI/JSONOutput.swift
# and Sources/MutationModel/QualityGate.swift.
- name: Operational failure (JSONErrorEnvelope) is never read as a pass
shell: bash
run: |
set -euo pipefail
printf '{"schemaVersion":1,"ok":false,"error":{"code":"reportUnreadable","message":"could not read report.json","remedy":"check --report points at a real report"}}' > gate-result.json
export GITHUB_STEP_SUMMARY="$RUNNER_TEMP/summary-error.md"
: > "$GITHUB_STEP_SUMMARY"
Scripts/action/summarize-gate.sh gate-result.json
cat "$GITHUB_STEP_SUMMARY"
grep -q 'operational failure' "$GITHUB_STEP_SUMMARY"
grep -q 'reportUnreadable' "$GITHUB_STEP_SUMMARY"
# `!` on its own line is exempt from `errexit` — a bare `! grep ...`
# matching would silently NOT fail this script under `set -e`. An
# explicit `if` avoids that trap.
if grep -qi 'quality gate passed' "$GITHUB_STEP_SUMMARY"; then
echo "::error::an operational failure must never render as a quality-gate pass" >&2
exit 1
fi
if grep -qi 'quality gate failed' "$GITHUB_STEP_SUMMARY"; then
echo "::error::an operational failure must never render as a quality-gate failure (it never reached a verdict)" >&2
exit 1
fi
- name: A genuine failing verdict still reads as a failure, not an operational error
shell: bash
run: |
set -euo pipefail
printf '{"schemaVersion":1,"passed":false,"violations":[{"kind":"effectiveScore","detail":"62%% < 70%% minimum"}]}' > gate-result.json
export GITHUB_STEP_SUMMARY="$RUNNER_TEMP/summary-fail.md"
: > "$GITHUB_STEP_SUMMARY"
Scripts/action/summarize-gate.sh gate-result.json
cat "$GITHUB_STEP_SUMMARY"
grep -q ':x: Mutation quality gate failed' "$GITHUB_STEP_SUMMARY"
grep -q 'effectiveScore' "$GITHUB_STEP_SUMMARY"
- name: A genuine passing verdict reads as a pass
shell: bash
run: |
set -euo pipefail
printf '{"schemaVersion":1,"passed":true,"violations":[]}' > gate-result.json
export GITHUB_STEP_SUMMARY="$RUNNER_TEMP/summary-pass.md"
: > "$GITHUB_STEP_SUMMARY"
Scripts/action/summarize-gate.sh gate-result.json
grep -q ':white_check_mark: Mutation quality gate passed' "$GITHUB_STEP_SUMMARY"
- name: A missing gate-result.json is reported honestly, not silently
shell: bash
run: |
set -euo pipefail
rm -f gate-result.json
export GITHUB_STEP_SUMMARY="$RUNNER_TEMP/summary-missing.md"
: > "$GITHUB_STEP_SUMMARY"
Scripts/action/summarize-gate.sh gate-result.json
grep -q 'did not produce a result' "$GITHUB_STEP_SUMMARY"
- name: preflight-capabilities.sh fails closed against a mutantkit build with no gate --json
shell: bash
run: |
set -euo pipefail
fake_bin="$RUNNER_TEMP/fake-mutantkit-bin"
mkdir -p "$fake_bin"
cat > "$fake_bin/mutantkit" <<'SCRIPT'
#!/usr/bin/env bash
if [ "$1" = "--version" ]; then
echo "mutantkit 0.2.0"
exit 0
fi
case "$1 $2" in
"gate --help") echo "OPTIONS: --report <report> --baseline <baseline>" ;;
"run --help") echo "OPTIONS: --report <report>" ;;
*) exit 0 ;;
esac
SCRIPT
chmod +x "$fake_bin/mutantkit"
set +e
PATH="$fake_bin:$PATH" Scripts/action/preflight-capabilities.sh
preflight_exit=$?
set -e
if [ "$preflight_exit" = "0" ]; then
echo "::error::preflight-capabilities.sh should have failed against a binary with no gate --json / run --also-report" >&2
exit 1
fi
ci-mode-diff:
name: "diff: missing ref fails closed; a fetched ref succeeds"
needs: build-from-source
runs-on: macos-15
steps:
# Shallow (default fetch-depth: 1): `origin/main` is not fetched, so
# it must not resolve.
- name: "Checkout (shallow — origin/main is NOT fetched)"
uses: actions/checkout@v4
with:
path: mutantkit-src
- uses: actions/download-artifact@v4
with:
name: mutantkit-from-source
path: test-binary
- run: chmod +x test-binary/mutantkit
shell: bash
# Staged as a SUBDIRECTORY of the mutantkit-src checkout, not at the
# workspace root: `git -C <project-root> rev-parse --verify <ref>`
# (orchestrate-ci.sh's own diff-ref preflight) discovers the nearest
# enclosing `.git` by walking upward from `project-root` — a bare
# workspace-root copy of just the fixture's files has no `.git` at
# all, which would fail this whole job's own point ("unfetched ref"
# vs. "not a git repository" are two different failures) for the
# wrong reason. A subdirectory of the real `mutantkit-src` checkout
# sees that checkout's own real fetch state while never overwriting
# mutantkit-src's own Package.swift/Sources/Tests.
- name: Stage the fixture project (inside the mutantkit-src checkout, so its git history applies)
shell: bash
run: |
set -euo pipefail
mkdir -p mutantkit-src/smoke-fixture
cp -R mutantkit-src/Fixtures/SwiftPackageMacOS/. mutantkit-src/smoke-fixture/
cat > mutantkit-src/smoke-fixture/mutantkit.yml <<'YAML'
version: 1
project:
kind: swiftPackageMacOS
sources:
include: [Sources/**]
operators:
profile: default
execution:
strategy: isolated
workers: 2
reports: [console]
YAML
- name: "diff: origin/main, unfetched — expected to fail closed, cheaply"
id: missing-diff
continue-on-error: true
uses: ./mutantkit-src
with:
mode: ci
project-root: mutantkit-src/smoke-fixture
internal-test-binary-dir: ${{ github.workspace }}/test-binary
diff: origin/main
- name: Assert it failed closed before plan ever ran
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.missing-diff.outcome }}" != "failure" ]; then
echo "::error::expected an unfetched diff base to fail closed, got outcome '${{ steps.missing-diff.outcome }}'" >&2
exit 1
fi
if [ -e mutantkit-src/smoke-fixture/plan.json ]; then
echo "::error::plan.json exists — the diff-ref preflight should have failed before mutantkit plan ever ran" >&2
exit 1
fi
# A second, independent checkout in the same job, this time with the
# full history the first step's own error message points at —
# `origin/main` now resolves and planning succeeds. Re-stages the
# fixture too: a fresh `actions/checkout` with the same `path:`
# replaces mutantkit-src wholesale, taking smoke-fixture with it.
- name: "Checkout (fetch-depth: 0 — origin/main now resolves)"
uses: actions/checkout@v4
with:
path: mutantkit-src
fetch-depth: 0
- name: Re-stage the fixture project after the deeper checkout
# `actions/checkout` resets/cleans an already-present `path:`
# directory, taking the untracked `smoke-fixture/` subdirectory
# staged above with it — recreated here identically.
shell: bash
run: |
set -euo pipefail
mkdir -p mutantkit-src/smoke-fixture
cp -R mutantkit-src/Fixtures/SwiftPackageMacOS/. mutantkit-src/smoke-fixture/
cat > mutantkit-src/smoke-fixture/mutantkit.yml <<'YAML'
version: 1
project:
kind: swiftPackageMacOS
sources:
include: [Sources/**]
operators:
profile: default
execution:
strategy: isolated
workers: 2
reports: [console]
YAML
- name: "diff: origin/main, fetched — expected to plan successfully"
id: fetched-diff
uses: ./mutantkit-src
with:
mode: ci
project-root: mutantkit-src/smoke-fixture
internal-test-binary-dir: ${{ github.workspace }}/test-binary
diff: origin/main
- name: Assert planning succeeded with a real diff scope
shell: bash
run: |
set -euo pipefail
test -s mutantkit-src/smoke-fixture/plan.json || { echo "::error::plan.json was not produced against a fetched diff base"; exit 1; }
ci-mode-report-isolation:
name: "multi-invocation isolation: a failed invocation never inherits or leaks a prior invocation's stale report/summary/artifact"
needs: build-from-source
runs-on: macos-15
steps:
# Shallow (default fetch-depth: 1): `origin/main` is not fetched, so
# invocation B below (diff: origin/main) is guaranteed to fail at the
# diff-ref preflight — the same deterministic failure `ci-mode-diff`
# above already proves — before doctor/plan/run ever run. That is the
# scenario this job needs: an invocation that fails before writing
# anything, against a project-root a *previous*, successful invocation
# already wrote real report.json/gate-result.json/.mutantkit/* into.
- name: "Checkout (shallow — origin/main is NOT fetched)"
uses: actions/checkout@v4
with:
path: mutantkit-src
- uses: actions/download-artifact@v4
with:
name: mutantkit-from-source
path: test-binary
- run: chmod +x test-binary/mutantkit
shell: bash
- name: Stage the fixture project (inside the mutantkit-src checkout, so its git history applies)
shell: bash
run: |
set -euo pipefail
mkdir -p mutantkit-src/smoke-fixture-isolation
cp -R mutantkit-src/Fixtures/SwiftPackageMacOS/. mutantkit-src/smoke-fixture-isolation/
cat > mutantkit-src/smoke-fixture-isolation/mutantkit.yml <<'YAML'
version: 1
project:
kind: swiftPackageMacOS
sources:
include: [Sources/**]
operators:
profile: default
execution:
strategy: isolated
workers: 2
reports: [console]
YAML
- name: "Invocation A — a normal, successful run leaves real report/summary files behind"
id: invocation-a
uses: ./mutantkit-src
with:
mode: ci
project-root: mutantkit-src/smoke-fixture-isolation
internal-test-binary-dir: ${{ github.workspace }}/test-binary
artifact-name: mutantkit-report-isolation-a
- name: Assert invocation A really did produce all four report files
shell: bash
run: |
set -euo pipefail
for f in report.json gate-result.json .mutantkit/report.html .mutantkit/summary.md; do
test -s "mutantkit-src/smoke-fixture-isolation/$f" \
|| { echo "::error::invocation A did not produce $f — this scenario needs it present first, to prove invocation B does not inherit it"; exit 1; }
done
- name: "Invocation B — same project-root, an unfetched diff ref fails before doctor/plan/run ever run"
id: invocation-b
continue-on-error: true
uses: ./mutantkit-src
with:
mode: ci
project-root: mutantkit-src/smoke-fixture-isolation
internal-test-binary-dir: ${{ github.workspace }}/test-binary
diff: origin/main
artifact-name: mutantkit-report-isolation-b
- name: Assert invocation B really did fail (the scenario this job needs)
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.invocation-b.outcome }}" != "failure" ]; then
echo "::error::expected invocation B (unfetched diff ref) to fail closed, got outcome '${{ steps.invocation-b.outcome }}'" >&2
exit 1
fi
- name: Assert invocation B's project-root no longer carries invocation A's stale report files
shell: bash
run: |
set -euo pipefail
for f in report.json gate-result.json .mutantkit/report.html .mutantkit/summary.md; do
path="mutantkit-src/smoke-fixture-isolation/$f"
if [ -e "$path" ]; then
echo "::error::$path still exists after invocation B failed before producing one of its own — this is invocation A's stale file leaking across invocations in the same job" >&2
exit 1
fi
done
# Checked directly on the runner's own disk, not via
# upload-artifact/download-artifact: `stage-artifacts.sh` staged
# nothing this invocation (its four sources are all correctly absent,
# per the assertion above), so `actions/upload-artifact@v4` may not
# even create an artifact for a genuinely-empty directory — a
# round trip through download-artifact would be testing upload-
# artifact's own empty-artifact behavior, not this fix. The staging
# directory itself is a fixed path under `runner.temp`, shared by
# every invocation in this job, and is exactly what `stage-
# artifacts.sh`'s own `rm -rf` fix (P13 review) protects.
- name: Assert the shared artifact-staging directory carries none of invocation A's stale files
shell: bash
run: |
set -euo pipefail
staging="$RUNNER_TEMP/mutantkit-artifact-staging"
if [ -d "$staging" ]; then
leftover="$(find "$staging" -type f)"
if [ -n "$leftover" ]; then
echo "::error::the shared artifact-staging directory still contains files after invocation B failed before staging any of its own — invocation A's stale files leaked through:" >&2
echo "$leftover" >&2
exit 1
fi
fi
ci-mode-input-hardening:
name: "inputs are treated as data, never as shell source (project-root/baseline-scope with shell metacharacters)"
needs: build-from-source
runs-on: macos-15
steps:
- name: Checkout mutantkit (action.yml)
uses: actions/checkout@v4
with:
path: mutantkit-src
- uses: actions/download-artifact@v4
with:
name: mutantkit-from-source
path: test-binary
- run: chmod +x test-binary/mutantkit
shell: bash
# Every occurrence of `${{ inputs.* }}` (and step outputs derived from
# one) used to be interpolated directly into action.yml's own `run:`
# script bodies — GitHub substitutes that text *before* bash ever
# parses it, so a value containing shell metacharacters becomes shell
# syntax, not inert data (an independent audit's finding, fixed by
# routing every such value through `env:` instead). This directory
# name and the `baseline-scope` input below are deliberately shaped
# like the exact failure mode: spaces, a double quote, and a
# `$(...)`-shaped substring that a vulnerable script would evaluate as
# a real command substitution.
#
# Single-quoted in bash so the literal characters (including `$(...)`)
# land in the directory name verbatim, never evaluated here either.
- name: Stage the fixture under an adversarially-named project-root
shell: bash
run: |
set -euo pipefail
project_root='adversarial project root $(marker) "quoted"'
mkdir -p "$project_root"
cp -R "mutantkit-src/Fixtures/SwiftPackageMacOS/." "$project_root/"
cat > "$project_root/mutantkit.yml" <<'YAML'
version: 1
project:
kind: swiftPackageMacOS
sources:
include: [Sources/**]
operators:
profile: default
execution:
strategy: isolated
workers: 2
reports: [console]
YAML
rm -f "$RUNNER_TEMP/mutantkit-injection-marker"
# `baseline-scope`'s value is a real `$(touch ...)` command
# substitution *as text* — GitHub only substitutes `${{ runner.temp }}`
# to build this string once, here; it never executes `touch` itself.
# If action.yml's own scripts ever evaluate this string as shell
# source again, the touch really runs and the marker file appears.
- name: "Run mutantkit via action.yml with adversarial project-root and baseline-scope inputs"
id: mutantkit
uses: ./mutantkit-src
with:
mode: ci
project-root: 'adversarial project root $(marker) "quoted"'
internal-test-binary-dir: ${{ github.workspace }}/test-binary
baseline-scope: "$(touch ${{ runner.temp }}/mutantkit-injection-marker)"
artifact-name: mutantkit-report-input-hardening
- name: Assert no injected command ran, and the adversarial project-root was still used as real data
shell: bash
run: |
set -euo pipefail
if [ -f "$RUNNER_TEMP/mutantkit-injection-marker" ]; then
echo "::error::an input value was evaluated as shell source instead of treated as literal data" >&2
exit 1
fi
project_root='adversarial project root $(marker) "quoted"'
test -s "$project_root/report.json" \
|| { echo "::error::the run against an adversarially-named project-root did not produce a report.json"; exit 1; }
ci-mode-baseline:
name: "baseline cache: fresh miss, same-SHA rerun has no save collision, scopes stay isolated"
needs: build-from-source
runs-on: macos-15
# action.yml's own baseline-save steps require `github.event_name !=
# 'pull_request'` on purpose (a PR run must never write the target
# branch's baseline before it has merged — see action.yml's own
# comment on "Save mutation baseline"). This job's whole point is
# proving a save happened and a same-SHA rerun then hit it, which is
# structurally impossible to observe on a `pull_request` event without
# weakening that real safety rail just to make a test pass — so this
# job is skipped there and instead runs on `workflow_dispatch` and the
# weekly `schedule`, both real non-PR events.
if: github.event_name != 'pull_request'
steps:
- name: Checkout mutantkit (action.yml)
uses: actions/checkout@v4
with:
path: mutantkit-src
- uses: actions/download-artifact@v4
with:
name: mutantkit-from-source
path: test-binary
- run: chmod +x test-binary/mutantkit
shell: bash
- name: Stage the fixture project
shell: bash
run: |
set -euo pipefail
cp -R mutantkit-src/Fixtures/SwiftPackageMacOS/. .
cat > mutantkit.yml <<'YAML'
version: 1
project:
kind: swiftPackageMacOS
sources:
include: [Sources/**]
operators:
profile: default
execution:
strategy: isolated
workers: 2
reports: [console]
YAML
# `baseline-scope` below is unique to this workflow run (`github.run_id`)
# so this job never collides with a baseline any other run/job saved —
# both invocations below start from a guaranteed-fresh key.
- name: "Invocation 1 (scope A) — expect a cache miss, then a save"
id: scope-a-first
uses: ./mutantkit-src
with:
mode: ci
internal-test-binary-dir: ${{ github.workspace }}/test-binary
baseline-scope: smoke-${{ github.run_id }}-A
artifact-name: mutantkit-baseline-a1
- name: Assert invocation 1 was a cache miss
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.scope-a-first.outputs.baseline-cache-hit }}" = "true" ]; then
echo "::error::expected a fresh scope's first run to be a cache miss" >&2
exit 1
fi
# Same scope, same SHA (same workflow run) — the shape of a workflow
# rerun. The point of this step: it must succeed at all. A broken
# `steps.restore-baseline.outputs.cache-hit != 'true'` guard on the
# save step would make `actions/cache/save` fail outright on this
# exact-key collision (P13 review, item 11) — this step failing at all
# is the regression signal, not any particular assertion below it.
- name: "Invocation 2 (scope A again, same SHA) — same-SHA rerun must not collide on save"
id: scope-a-second
uses: ./mutantkit-src
with:
mode: ci
internal-test-binary-dir: ${{ github.workspace }}/test-binary
baseline-scope: smoke-${{ github.run_id }}-A
artifact-name: mutantkit-baseline-a2
- name: Assert invocation 2 saw invocation 1's baseline (exact-key hit)
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.scope-a-second.outputs.baseline-cache-hit }}" != "true" ]; then
echo "::error::expected the second same-scope/same-SHA invocation to be an exact cache hit" >&2
exit 1
fi
- name: "Invocation 3 (scope B) — a different scope must not see scope A's baseline"
id: scope-b
uses: ./mutantkit-src
with:
mode: ci
internal-test-binary-dir: ${{ github.workspace }}/test-binary
baseline-scope: smoke-${{ github.run_id }}-B
artifact-name: mutantkit-baseline-b
- name: Assert scope B did not inherit scope A's baseline
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.scope-b.outputs.baseline-cache-hit }}" = "true" ]; then
echo "::error::scope B reported a cache hit on its very first run — baseline-scope is not isolating the two scopes" >&2
exit 1
fi
# `baseline-cache-hit` above only proves the cache action's own KEY
# missed — it says nothing about whether `gate` was actually handed a
# stale on-disk file left over from scope A's own invocation earlier
# in this same job (the on-disk restore/save path used to be fixed
# regardless of scope; see action.yml's own comment on `baseline-dir`
# and `baseline-applied`). This checks the fact that actually matters:
# what `gate --baseline` was really given.
- name: Assert scope B's gate step did not apply a baseline it never restored
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.scope-b.outputs.baseline-applied }}" = "true" ]; then
echo "::error::scope B's gate step applied a baseline it never restored — the on-disk baseline path is leaking across differently-scoped invocations in the same job" >&2
exit 1
fi