Swift Testing direct-invocation coverage backend (substrate, not yet wired in) #157
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| # `labeled` added (default is only opened/synchronize/reopened) so a | |
| # coordinator can apply `ci:full`/`ci:merge-ready` to an already-pushed | |
| # HEAD and get a fresh CI run reflecting it immediately, without needing | |
| # an empty commit to re-trigger anything. See the `route` job below for | |
| # what these labels actually do. | |
| types: [opened, synchronize, reopened, labeled] | |
| # The deliberate "run the complete merge gate right now" trigger (see the | |
| # `route` job's `workflow_dispatch` handling below) -- a human/coordinator | |
| # picks the branch/PR ref in the Actions UI ("Run workflow") once review | |
| # has converged, rather than paying full-matrix cost on every intermediate | |
| # review-round push. No inputs: dispatching this workflow at all always | |
| # means "run everything." | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| # The exact suites tagged `.subprocessExclusive` under | |
| # Tests/MutantKitTests/Unit/ -- see Tests/MutantKitTests/Support/ | |
| # SubprocessTestGate.swift's own doc comment. Every one of them spawns a | |
| # real OS subprocess, and several exercise genuinely timing-sensitive | |
| # machinery (the stall watchdog, real descendant reaping, fd-inheritance | |
| # races) that no fake clock or fake process can stand in for -- exactly | |
| # the `unit-system` bucket below. This project already curated and | |
| # maintains this exact list for its own subprocess-contention-avoidance | |
| # reasons; reusing it as the unit-fast/unit-system boundary means there is | |
| # one place that decides "this is a real-process/timing test," not two | |
| # independently-maintained lists that can silently drift apart. | |
| # | |
| # `swift test` only supports regex `--filter`/`--skip` (no CLI tag-based | |
| # selection -- confirmed against this toolchain's own `swift test --help`), | |
| # so this is a maintained, explicit suite-name list, not a generated one. | |
| # Update it if a suite gains or loses `.subprocessExclusive` under | |
| # Tests/MutantKitTests/Unit/ (Tests/MutantKitTests/Acceptance/ and | |
| # Tests/BenchmarkRunnerTests/ also use the trait, but those live in a | |
| # different job/target already and are out of scope for this split). | |
| UNIT_SYSTEM_SUITE_REGEX: "SchemataRuntimeProtocolV3Tests|SchemataBuiltImageInspectionTests|ProcessTreeTests|XcodeBuildAdapterUninstallFailureTests|ProcessSupervisorResidueTests|ProcessSupervisorOutputCompletenessTests|RunContextProbeOutputCompletenessTests|ProcessSupervisorStallDetectionTests|MachOReceiptExtractorTests|ProcessSupervisorFileDescriptorLeakTests" | |
| jobs: | |
| # Fastest feedback of all: static analysis needs no build. SwiftLint and | |
| # SwiftFormat run against the whole tree using the repo's own configs | |
| # (.swiftlint.yml / .swiftformat), which were tuned against this | |
| # codebase's actual style rather than stock defaults. | |
| # | |
| # `swiftlint lint` runs `--strict --baseline .swiftlint-baseline.json`: | |
| # a handful of complexity/length rules (function_body_length, | |
| # type_body_length, cyclomatic_complexity, file_length, large_tuple) fire | |
| # against pre-existing debt — concentrated in, but not limited to, a few | |
| # deliberately large orchestration files (see .swiftlint.yml's comments | |
| # for the full breakdown, including which CLI command handlers are | |
| # baselined too) — that aren't worth a risky structural refactor just to | |
| # silence a linter. The baseline freezes exactly those pre-existing | |
| # violations by identity (rule + file + line), so `--strict` still fails | |
| # the build on any *new* violation anywhere, including a new one in an | |
| # already-baselined file. Shrink the baseline over time by fixing an | |
| # entry and regenerating with | |
| # `swiftlint lint --write-baseline .swiftlint-baseline.json`; never grow | |
| # it to paper over new debt. | |
| # | |
| # SwiftLint is pinned to an exact version (matching the version the | |
| # baseline was generated with) rather than `brew install`'s floating | |
| # latest: baseline identity and violation detection are both | |
| # SwiftLint-version-sensitive, so an unpinned upgrade could silently | |
| # change what the baseline recognizes, in either direction. Bump | |
| # SWIFTLINT_VERSION and regenerate .swiftlint-baseline.json together, as | |
| # one deliberate change, not independently. | |
| lint: | |
| name: Lint & format check | |
| runs-on: macos-15 | |
| # Generous relative to observed reality (~20-25s on this exact job) — | |
| # bounds a hang (a stuck `brew install`/download, say) rather than | |
| # tuning for the common case. See the `unit-fast` job's own comment for | |
| # why every job here now has an explicit ceiling. | |
| timeout-minutes: 10 | |
| env: | |
| SWIFTLINT_VERSION: "0.63.2" | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install SwiftFormat | |
| run: brew install swiftformat | |
| - name: Install pinned SwiftLint | |
| run: | | |
| curl -fsSL -o portable_swiftlint.zip \ | |
| "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" | |
| unzip -o portable_swiftlint.zip swiftlint | |
| chmod +x swiftlint | |
| sudo mv swiftlint /usr/local/bin/swiftlint | |
| rm portable_swiftlint.zip | |
| installed_version="$(swiftlint version)" | |
| if [ "$installed_version" != "$SWIFTLINT_VERSION" ]; then | |
| echo "::error::Installed SwiftLint $installed_version does not match pinned SWIFTLINT_VERSION $SWIFTLINT_VERSION" | |
| exit 1 | |
| fi | |
| - name: SwiftLint | |
| run: swiftlint lint --strict --config .swiftlint.yml --baseline .swiftlint-baseline.json Sources Tests | |
| - name: SwiftFormat (check only) | |
| run: swiftformat --lint --config .swiftformat . | |
| # Staged-merge-gate routing: decides, once per push/PR event, whether this | |
| # run gets the full matrix or a narrower, path-targeted slice of it. Runs | |
| # on a plain Linux runner (no Swift/Xcode toolchain needed -- this job | |
| # only reads `git diff` output and PR label metadata) and finishes in | |
| # seconds, so gating the ~20 acceptance jobs behind `needs: route` does | |
| # not recreate the sequential bottleneck this restructuring exists to | |
| # remove (that bottleneck was every acceptance job waiting on a full | |
| # `unit` build+test run, ~15+ minutes; this is a `git diff` and some | |
| # `grep`, not a rebuild of anything). | |
| # | |
| # Every branch below that is not a deliberate "run everything" signal | |
| # (push to main, workflow_dispatch, an override label, the trust-critical | |
| # path group) still ends by checking for changed paths outside every known | |
| # targeted group, and fails toward `run_full=true` if it finds any -- | |
| # this project's own established "unknown -> run" CI-safe-skip principle | |
| # (see e.g. `scripts/assert-tests-ran.sh`'s own doc comment for the same | |
| # stance applied to a filter matching zero tests). A classifier's job here | |
| # is never to make a false skip look like a pass. | |
| route: | |
| name: Route CI scope (full vs. targeted) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| run_full: ${{ steps.classify.outputs.run_full }} | |
| run_schemata_targeted: ${{ steps.classify.outputs.run_schemata_targeted }} | |
| selected_fixtures: ${{ steps.classify.outputs.selected_fixtures }} | |
| # A real, already-filtered `{"include": [...]}` GitHub Actions matrix | |
| # object -- see Scripts/ci-route.sh's own header comment and the | |
| # `acceptance` job's `strategy.matrix` below. Built from the single | |
| # source of truth at Scripts/ci-fixtures.json, so an unselected | |
| # fixture never creates a runner job at all (rather than creating one | |
| # whose steps are merely skipped) and the full-matrix case is never a | |
| # second, independently-maintained copy of the fixture list. | |
| acceptance_matrix: ${{ steps.classify.outputs.acceptance_matrix }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # Full history, not the default shallow clone: classification | |
| # needs a real `git diff` between the PR's base and head commits, | |
| # both of which must actually be present locally to diff at all. | |
| fetch-depth: 0 | |
| - name: Classify changed paths -> full matrix or a targeted acceptance slice | |
| id: classify | |
| shell: bash | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} | |
| run: | | |
| set -euo pipefail | |
| changed="" | |
| if [ "$EVENT_NAME" = "pull_request" ]; then | |
| changed="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" 2>/dev/null || true)" | |
| if [ -n "$changed" ]; then | |
| echo "Changed files (base $BASE_SHA -> head $HEAD_SHA):" | |
| echo "$changed" | |
| fi | |
| fi | |
| # The actual group-to-fixture classification logic lives in | |
| # Scripts/ci-route.sh, not here -- see that script's own header | |
| # comment, and Tests/MutantKitTests/Unit/CIRouteClassificationTests.swift | |
| # for its regression coverage. This step is now just plumbing: | |
| # gather the two real signals (changed paths, PR labels) and hand | |
| # them to the script exactly as it expects them. | |
| result="$(printf '%s' "$changed" | Scripts/ci-route.sh "$EVENT_NAME" "$LABELS_JSON")" | |
| echo "Routing result: $result" | |
| run_full="$(printf '%s' "$result" | jq -r '.run_full')" | |
| run_schemata_targeted="$(printf '%s' "$result" | jq -r '.run_schemata_targeted')" | |
| selected_fixtures="$(printf '%s' "$result" | jq -c '.selected_fixtures')" | |
| acceptance_matrix="$(printf '%s' "$result" | jq -c '.acceptance_matrix')" | |
| reason="$(printf '%s' "$result" | jq -r '.reason')" | |
| echo "run_full=$run_full" >> "$GITHUB_OUTPUT" | |
| echo "run_schemata_targeted=$run_schemata_targeted" >> "$GITHUB_OUTPUT" | |
| echo "selected_fixtures=$selected_fixtures" >> "$GITHUB_OUTPUT" | |
| echo "acceptance_matrix=$acceptance_matrix" >> "$GITHUB_OUTPUT" | |
| echo "::notice::CI routing decision: run_full=$run_full, run_schemata_targeted=$run_schemata_targeted, selected_fixtures=$selected_fixtures -- $reason" | |
| # Build once, shared by every job below that runs `swift test` against the | |
| # same debug `--build-tests` output: unit-fast, unit-system, every | |
| # acceptance matrix entry, and (for their shared debug portion only) | |
| # ror-schemata-differential / ios-simulator-schemata-runtime. Before this | |
| # job existed, each of those ~19-20 job/matrix-entry instances ran its own | |
| # byte-identical `swift build --build-tests` from scratch in parallel -- | |
| # under GitHub-hosted macOS runners' real, constrained concurrency, that | |
| # many redundant from-scratch builds queues in concurrency-limited waves, | |
| # paying full build cost in every wave rather than once. Building it here, | |
| # exactly once, and having every consumer download the result and run | |
| # `swift test --skip-build` instead turns that into one build plus N cheap | |
| # skip-build test runs. | |
| # | |
| # Validated locally before wiring this in: `swift test --skip-build` | |
| # against a `.build` produced by a *different* `swift build --build-tests` | |
| # invocation -- same path, a different path, and a real zip round-trip -- | |
| # never recompiles anything and never produces a stale or incorrect | |
| # result. It does not even revalidate the build database against the | |
| # extraction path; it just runs whatever binary already sits at the | |
| # conventional `.build/<triple>/debug` location. The artifact packaged | |
| # below carries exactly the subset that trial found load-bearing: the | |
| # built products directory, the `.build/debug` symlink pointing at it, and | |
| # the three small SwiftPM state files -- not `checkouts/`, `repositories/`, | |
| # `artifacts/`, `plugins/`, or `prebuilts/`, which are the large majority | |
| # of `.build`'s size and are regenerated for free (no network) from each | |
| # consuming job's own "Cache SwiftPM dependencies" step below, which every | |
| # job here already has. | |
| # | |
| # Runs unconditionally rather than gated on `route`'s own full-vs-targeted | |
| # decision: `unit-fast` and `acceptance` both run on every single push/PR | |
| # event regardless of that routing, so at least one real consumer always | |
| # needs this build's output anyway. Conditionally gating this job would | |
| # only reproduce `route`'s own branching a second time for no actual | |
| # savings, in exchange for a strictly more complex `if:` to get right. | |
| build: | |
| name: Build (debug, --build-tests) | |
| runs-on: macos-15 | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| .build/checkouts | |
| .build/repositories | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-spm- | |
| - name: Toolchain | |
| run: swift --version && xcodebuild -version | |
| - name: Build | |
| shell: bash | |
| run: | | |
| set -o pipefail | |
| swift build --build-tests 2>&1 | tee build.log | |
| # Only the pieces a downstream `swift test --skip-build` actually | |
| # needs in order to run without recompiling anything -- see this job's | |
| # own comment above for how that subset was determined. A tar archive | |
| # (not a raw upload-artifact directory upload) specifically to carry | |
| # `.build/debug`'s symlink and every binary's executable bit through | |
| # the upload/download round-trip intact and unambiguous. | |
| - name: Package build output | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| tar -czf build-tests.tar.gz \ | |
| .build/debug \ | |
| .build/*-apple-macosx/debug \ | |
| .build/build.db \ | |
| .build/debug.yaml \ | |
| .build/workspace-state.json | |
| - name: Upload build output | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-tests | |
| path: build-tests.tar.gz | |
| retention-days: 1 | |
| - name: Upload build failure logs | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-failure-logs | |
| if-no-files-found: ignore | |
| path: build.log | |
| # Fast feedback: everything that does not spawn a real, long-lived | |
| # subprocess and does not depend on real OS timing -- pure model/parser/ | |
| # planner/classifier/CLI-decision-logic tests and deterministic fixtures. | |
| # Always runs, on every push and every PR review-round push, with no | |
| # dependency on `route`'s classification: this is the "ordinary per-push" | |
| # gate item D describes, deliberately unconditional so it never waits on | |
| # anything slower than itself. | |
| # | |
| # Split out of the former single `unit` job specifically so this job's | |
| # own runtime is not held hostage by `unit-system`'s real-subprocess, | |
| # real-timing suites (see that job's own comment for the incident history | |
| # that makes those suites slow and occasionally flaky under contention). | |
| # This job keeps the exact same build+test+stall-watchdog shape the | |
| # former `unit` job had -- defensively, not because the originating hang | |
| # incident lived here (it did not; see `unit-system`) but because a | |
| # regression of the same *class* is not provably impossible here either, | |
| # and the watchdog is cheap insurance either way. | |
| unit-fast: | |
| name: Unit tests (fast) | |
| runs-on: macos-15 | |
| needs: build | |
| # Generous relative to known-good local runtime, bounded so a hang | |
| # fails fast and visibly instead of silently burning CI minutes. See | |
| # `unit-system`'s own comment for the full "why every job here has an | |
| # explicit ceiling" history. | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # SwiftPM re-clones swift-argument-parser/swift-syntax/Yams and | |
| # re-resolves their versions from scratch on every job today -- no | |
| # caching existed anywhere before this. These are immutable dependency | |
| # snapshots once resolved, so caching them by Package.resolved's own | |
| # hash is safe: a version change naturally busts the key. This same | |
| # block is repeated (not shared via a composite action, to keep this | |
| # single-file workflow's diff obvious) on every job here that actually | |
| # builds, and on codeql.yml's own build step. | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| .build/checkouts | |
| .build/repositories | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-spm- | |
| - name: Toolchain | |
| shell: bash | |
| run: | | |
| swift --version && xcodebuild -version | |
| echo "CPU count: $(sysctl -n hw.ncpu)" | |
| # Built once, by the `build` job above -- see its own comment for why | |
| # this replaces a from-scratch `swift build --build-tests` here. | |
| - name: Download build output | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-tests | |
| - name: Extract build output | |
| shell: bash | |
| run: tar -xzf build-tests.tar.gz | |
| # Same stall-watchdog mechanism as `unit-system`'s own `Test` step -- | |
| # see there for the full incident history and the reasoning behind | |
| # every constant below (240s stall threshold, the two-sample capture, | |
| # exiting 124 instead of relying on `timeout-minutes`, etc). Kept | |
| # identical here rather than factored into a composite action because | |
| # this repo has no existing composite-action-reuse precedent for its | |
| # own CI steps (each acceptance-family job below already duplicates | |
| # its own Toolchain/Build/Upload steps the same way). | |
| # | |
| # The one real difference: `--skip "$UNIT_SYSTEM_SUITE_REGEX"` (defined | |
| # at workflow level, top of this file) excludes exactly the suites | |
| # `unit-system` runs instead, so no test runs in both jobs and none | |
| # falls through the gap between them. | |
| - name: Test | |
| shell: bash | |
| env: | |
| SWT_EXPERIMENTAL_MAXIMUM_PARALLELIZATION_WIDTH: "2" | |
| run: | | |
| set -o pipefail | |
| mkdir -p samples | |
| swift test --skip-build --skip "$UNIT_SYSTEM_SUITE_REGEX" > test.log 2>&1 & | |
| test_pid=$! | |
| echo "swift test started, pid=$test_pid" | |
| last_lines=0 | |
| stall_seconds=0 | |
| while kill -0 "$test_pid" 2>/dev/null; do | |
| sleep 15 | |
| lines=$(wc -l < test.log | tr -d ' ') | |
| if [ "$lines" -eq "$last_lines" ]; then | |
| stall_seconds=$((stall_seconds + 15)) | |
| else | |
| stall_seconds=0 | |
| fi | |
| last_lines=$lines | |
| echo "--- heartbeat $(date -u +%H:%M:%S) --- test.log lines: $lines (no growth for ${stall_seconds}s) ---" | |
| ps -eo pid,ppid,%cpu,%mem,etime,comm | grep -iE "swift|xctest" | grep -v grep || true | |
| tail -n 5 test.log | |
| if [ "$stall_seconds" -ge 240 ]; then | |
| echo ">>> STALL DETECTED (test.log has not grown in ${stall_seconds}s) — capturing diagnostics <<<" | |
| ps -ef > samples/ps-tree.txt | |
| parent_pid=$(pgrep -f "swift-test" | head -1) | |
| if [ -n "$parent_pid" ]; then | |
| echo "sampling parent swift-test pid=$parent_pid (for completeness — the real work is in the helper below)" | |
| sample "$parent_pid" 5 -file "samples/sample-parent-${parent_pid}.txt" 2>&1 | tail -n 3 || true | |
| fi | |
| for helper_pid in $(pgrep -f "swiftpm-testing-helper"); do | |
| echo "sampling swiftpm-testing-helper pid=$helper_pid — the actual test-worker process" | |
| sample "$helper_pid" 5 -file "samples/sample-helper-${helper_pid}.txt" 2>&1 | tail -n 3 || true | |
| lsof -p "$helper_pid" > "samples/lsof-helper-${helper_pid}.txt" 2>&1 || true | |
| done | |
| echo ">>> killing the stuck process tree and failing this step (exit 124) so the job registers as an ordinary failure, not a GitHub-initiated cancellation <<<" | |
| kill -9 "$test_pid" 2>/dev/null | |
| pkill -9 -f swiftpm-testing-helper 2>/dev/null | |
| pkill -9 -f "swift-test" 2>/dev/null | |
| cat test.log | |
| exit 124 | |
| fi | |
| done | |
| wait "$test_pid" | |
| exit_code=$? | |
| echo "=== swift test exited with code $exit_code ===" | |
| # A filter/skip matching zero tests must fail this job, not pass | |
| # it silently -- see scripts/assert-tests-ran.sh. A real test | |
| # failure above must still win even if the count check passes. | |
| scripts/assert-tests-ran.sh test.log "unit-fast (all suites except: \$UNIT_SYSTEM_SUITE_REGEX)" || exit_code=1 | |
| cat test.log | |
| exit $exit_code | |
| - name: Upload unit-fast failure logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: unit-fast-failure-logs | |
| if-no-files-found: ignore | |
| path: | | |
| test.log | |
| samples/ | |
| - name: Publish unit-fast failure excerpt | |
| if: failure() && github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const marker = '<!-- mutantkit-ci-unit-fast-failure -->'; | |
| const readTail = (path) => { | |
| if (!fs.existsSync(path)) return ''; | |
| const lines = fs.readFileSync(path, 'utf8').split('\n'); | |
| return lines.slice(-180).join('\n'); | |
| }; | |
| // No build.log here anymore -- `swift build --build-tests` now | |
| // runs once in the shared `build` job (see its own failure-log | |
| // upload for that), not in this job, which only downloads the | |
| // already-built output and runs `swift test --skip-build`. | |
| const test = readTail('test.log'); | |
| const excerpt = (test || 'No captured test log was available.').slice(-30000); | |
| const body = `${marker}\n### Latest unit-fast CI failure\n\n\`\`\`text\n${excerpt}\n\`\`\``; | |
| const { owner, repo } = context.repo; | |
| const issue_number = context.issue.number; | |
| const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number }); | |
| const existing = comments.find(c => c.body && c.body.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number, body }); | |
| } | |
| # The other half of the former `unit` job: real-process ProcessSupervisor | |
| # tests, fd-inheritance, residue/reaping, and the drain-timeout/stall- | |
| # detector machinery -- exactly the suites tagged `.subprocessExclusive` | |
| # under Tests/MutantKitTests/Unit/ (`$UNIT_SYSTEM_SUITE_REGEX`, defined at | |
| # the top of this file). | |
| # | |
| # Deliberately NOT part of the ordinary per-push gate: unlike `unit-fast`, | |
| # this only runs when `route` says the full matrix is in play (push to | |
| # main, a `ci:full`/`ci:merge-ready` label, workflow_dispatch, or a | |
| # trust-critical/unclassified path match) -- see the `route` job above. | |
| # These are exactly the tests item F's "merge-candidate full CI" is meant | |
| # to still guarantee run before merge; they are not skipped, only moved | |
| # off the fast per-review-round-push path. | |
| # | |
| # A real, reproducible hang (this job's own `Test` step sitting for | |
| # 68+ minutes with no completion, versus ~15-20s for the identical | |
| # `swift test` locally) burned real Actions minutes on this account | |
| # before three compounding process-handling bugs were found and | |
| # fixed (Sources/BenchmarkRunner/ToolRunner.swift). This ceiling is | |
| # this project's own "nothing runs unbounded" principle applied to | |
| # its own CI: generous relative to known-good local runtime (cold | |
| # build + full unit suite) so it never fires on a real, if slow, run, | |
| # but bounded so a recurrence (this exact class of bug, or a new one) | |
| # fails fast and visibly instead of silently burning an hour+ again. | |
| unit-system: | |
| name: Unit tests (system / real-process) | |
| runs-on: macos-15 | |
| needs: | |
| - route | |
| - build | |
| if: success() && needs.route.outputs.run_full == 'true' | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| .build/checkouts | |
| .build/repositories | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-spm- | |
| - name: Toolchain | |
| shell: bash | |
| run: | | |
| swift --version && xcodebuild -version | |
| echo "CPU count: $(sysctl -n hw.ncpu)" | |
| # Built once, by the `build` job above -- see its own comment for why | |
| # this replaces a from-scratch `swift build --build-tests` here. | |
| - name: Download build output | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-tests | |
| - name: Extract build output | |
| shell: bash | |
| run: tar -xzf build-tests.tar.gz | |
| # Diagnostic instrumentation for the same real CI hang this job's | |
| # own `timeout-minutes` above exists for. `swift test` runs in the | |
| # background, writing straight to a file (never through a pipe | |
| # `tee`/GitHub's own log-streaming could stall on) — file-based | |
| # logging already ruled out a log-pipe-buffering artifact directly: | |
| # a prior run's own `test.log` genuinely froze (byte count static | |
| # for the full ~12 minutes before timeout), proving a real stall | |
| # inside the test process itself, not a reporting artifact. | |
| # | |
| # Detection: `test.log` not growing for 240s (sixteen 15s checks) is | |
| # treated as a stall — don't wait for this job's own 15-minute | |
| # `timeout-minutes` ceiling, which exists as an outer backstop only. | |
| # | |
| # 240s, not the original 75s: a real CI run on commit 8ed7d31 tripped | |
| # this detector, and both a stack sample and the raw heartbeat log | |
| # proved it was a false positive, not a real hang. The sample showed | |
| # the sole busy thread inside `ProcessSupervisor.wait()`'s own | |
| # ordinary poll loop (`usleep`/`nanosleep`), not stuck in any | |
| # Foundation-internal call; the heartbeat log showed `test.log` | |
| # frozen at a constant line count for exactly the-then-75s threshold | |
| # before the kill fired. Root cause: this same investigation's own | |
| # earlier widenings this session (`promptExitTimeoutSeconds` and | |
| # `eventuallyNoSurvivors`'s timeout, both raised 10-30s -> 60s, plus | |
| # `retryingKnownForkRaceWindow`'s up to 3 fresh attempts on the | |
| # documented fork-race miss in `ProcessSupervisorResidueTests.swift`) | |
| # legitimately raised the worst-case span a single test can run with | |
| # zero `test.log` output well past the old 75s threshold — a single | |
| # attempt alone can legitimately wait up to | |
| # `promptExitTimeoutSeconds` + `eventuallyNoSurvivors`'s ceiling | |
| # (60s + 60s = 120s) with no output at all before it either succeeds | |
| # or retries. 240s covers two such attempts back to back (a real, | |
| # if uncommon, case — not the fully compounded 3-attempt worst case, | |
| # which would need multiple independent rare fork-race misses to | |
| # land back to back and is treated as astronomically unlikely enough | |
| # not to design the stall threshold around) while staying well under | |
| # the 900s `timeout-minutes` outer backstop, so a genuine multi- | |
| # minute hang is still caught and sampled long before that backstop | |
| # would silently discard the evidence. | |
| # | |
| # On stall, this samples the *actual* stuck work, not just the | |
| # parent: `swift-test`'s own process typically shows only "waiting | |
| # on child" once sampled, since the real work — and therefore the | |
| # real stuck stack trace — runs in `swiftpm-testing-helper`. Both | |
| # are sampled, `swiftpm-testing-helper` first/foremost, plus | |
| # `lsof` on the helper to see directly whether it is holding an | |
| # unexpected pipe fd open (confirming or ruling out the | |
| # file-descriptor-inheritance hypothesis directly, rather than | |
| # inferring it). | |
| # | |
| # Then this step kills the stuck process tree itself and exits 124 | |
| # (the conventional "timed out" exit code) — deliberately *not* | |
| # left to this job's own `timeout-minutes`, which marks a job | |
| # `cancelled`, not `failed`; `if: failure()` (the existing | |
| # "Upload unit-system failure logs" step's own condition) does not | |
| # run on a `cancelled` job, so a GitHub-initiated timeout would | |
| # silently discard every sample this step just captured. Exiting | |
| # 124 from inside the step itself makes this an ordinary step | |
| # failure, which does trigger the upload. | |
| # | |
| # SWT_EXPERIMENTAL_MAXIMUM_PARALLELIZATION_WIDTH: CI-only (never set | |
| # for local development), added after a real, repeated CI failure | |
| # pattern in ProcessSupervisorResidueTests and similar tests that | |
| # depend on the OS actually finishing real, timing-sensitive work | |
| # (reaping a killed descendant, a stall watchdog's own deadline) in | |
| # a bounded window. This runner has confirmed only 3 vCPUs (see the | |
| # "Toolchain" step above); Swift Testing schedules every test's own | |
| # concurrency onto the cooperative thread pool by default, sized to | |
| # the *full* core count with no user-facing throttle -- `swift | |
| # test`'s own `--num-workers`/`--parallel` flags were tried first and | |
| # confirmed, by direct local experiment, to have zero effect on | |
| # Swift Testing's scheduling (they only govern legacy XCTest-style | |
| # process-level parallelism). This environment variable is Swift | |
| # Testing's own internal mechanism instead -- found via `strings` on | |
| # the Testing framework binary (it is not documented public API, and | |
| # its own name says "EXPERIMENTAL"), then verified directly, locally, | |
| # to actually bound real concurrent test execution before trusting | |
| # it here. Set to 2, not 1: leaves the supervisory/monitoring | |
| # machinery real headroom rather than fully serializing the whole | |
| # suite, which this project's own standing policy is not to do | |
| # blindly for a speed-vs-reliability trade this narrow a problem | |
| # does not need. If a future toolchain silently drops support for | |
| # this variable, the worst case is reverting to today's already- | |
| # documented behavior, not a regression beyond it. | |
| - name: Test | |
| shell: bash | |
| env: | |
| SWT_EXPERIMENTAL_MAXIMUM_PARALLELIZATION_WIDTH: "2" | |
| run: | | |
| set -o pipefail | |
| mkdir -p samples | |
| swift test --skip-build --filter "$UNIT_SYSTEM_SUITE_REGEX" > test.log 2>&1 & | |
| test_pid=$! | |
| echo "swift test started, pid=$test_pid" | |
| last_lines=0 | |
| stall_seconds=0 | |
| while kill -0 "$test_pid" 2>/dev/null; do | |
| sleep 15 | |
| lines=$(wc -l < test.log | tr -d ' ') | |
| if [ "$lines" -eq "$last_lines" ]; then | |
| stall_seconds=$((stall_seconds + 15)) | |
| else | |
| stall_seconds=0 | |
| fi | |
| last_lines=$lines | |
| echo "--- heartbeat $(date -u +%H:%M:%S) --- test.log lines: $lines (no growth for ${stall_seconds}s) ---" | |
| # The real work happens in child processes (swiftpm-testing-helper, | |
| # swift-package) the parent `swift` pid's own %cpu never reflects — | |
| # snapshotting every swift-related process, not just $test_pid, | |
| # is what actually shows whether real work is still happening. | |
| ps -eo pid,ppid,%cpu,%mem,etime,comm | grep -iE "swift|xctest" | grep -v grep || true | |
| tail -n 5 test.log | |
| if [ "$stall_seconds" -ge 240 ]; then | |
| echo ">>> STALL DETECTED (test.log has not grown in ${stall_seconds}s) — capturing diagnostics <<<" | |
| ps -ef > samples/ps-tree.txt | |
| parent_pid=$(pgrep -f "swift-test" | head -1) | |
| if [ -n "$parent_pid" ]; then | |
| echo "sampling parent swift-test pid=$parent_pid (for completeness — the real work is in the helper below)" | |
| sample "$parent_pid" 5 -file "samples/sample-parent-${parent_pid}.txt" 2>&1 | tail -n 3 || true | |
| fi | |
| for helper_pid in $(pgrep -f "swiftpm-testing-helper"); do | |
| echo "sampling swiftpm-testing-helper pid=$helper_pid — the actual test-worker process" | |
| sample "$helper_pid" 5 -file "samples/sample-helper-${helper_pid}.txt" 2>&1 | tail -n 3 || true | |
| lsof -p "$helper_pid" > "samples/lsof-helper-${helper_pid}.txt" 2>&1 || true | |
| done | |
| echo ">>> killing the stuck process tree and failing this step (exit 124) so the job registers as an ordinary failure, not a GitHub-initiated cancellation <<<" | |
| kill -9 "$test_pid" 2>/dev/null | |
| pkill -9 -f swiftpm-testing-helper 2>/dev/null | |
| pkill -9 -f "swift-test" 2>/dev/null | |
| cat test.log | |
| exit 124 | |
| fi | |
| done | |
| wait "$test_pid" | |
| exit_code=$? | |
| echo "=== swift test exited with code $exit_code ===" | |
| # A filter matching zero tests must fail this job, not pass it | |
| # silently -- see scripts/assert-tests-ran.sh (same policy the | |
| # acceptance jobs below already apply to their own --filter). | |
| scripts/assert-tests-ran.sh test.log "unit-system (\$UNIT_SYSTEM_SUITE_REGEX)" || exit_code=1 | |
| cat test.log | |
| exit $exit_code | |
| # `always()`, not `failure()`: a job that hits its own | |
| # `timeout-minutes` is reported as *cancelled*, not failed, and | |
| # `if: failure()` does not run for a cancelled job's steps — the | |
| # exact scenario this whole diagnostic exists to capture evidence | |
| # from. Uploading unconditionally is the only way the stack | |
| # samples and logs from a timeout are ever actually retained. | |
| - name: Upload unit-system failure logs | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: unit-system-failure-logs | |
| if-no-files-found: ignore | |
| path: | | |
| test.log | |
| samples/ | |
| - name: Publish unit-system failure excerpt | |
| if: failure() && github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const marker = '<!-- mutantkit-ci-unit-system-failure -->'; | |
| const readTail = (path) => { | |
| if (!fs.existsSync(path)) return ''; | |
| const lines = fs.readFileSync(path, 'utf8').split('\n'); | |
| return lines.slice(-180).join('\n'); | |
| }; | |
| // No build.log here anymore -- `swift build --build-tests` now | |
| // runs once in the shared `build` job (see its own failure-log | |
| // upload for that), not in this job, which only downloads the | |
| // already-built output and runs `swift test --skip-build`. | |
| const test = readTail('test.log'); | |
| const excerpt = (test || 'No captured test log was available.').slice(-30000); | |
| const body = `${marker}\n### Latest unit-system CI failure\n\n\`\`\`text\n${excerpt}\n\`\`\``; | |
| const { owner, repo } = context.repo; | |
| const issue_number = context.issue.number; | |
| const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number }); | |
| const existing = comments.find(c => c.body && c.body.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number, body }); | |
| } | |
| # The suites that build and mutate real projects. Slow, and the only thing that | |
| # proves the tool works rather than merely compiles: every wiring bug this | |
| # project has had — a sandbox handed the wrong excludes, xcodebuild pointed at | |
| # unmutated sources, concurrent mutants fighting over one simulator — was | |
| # invisible to the unit tests and produced a confident, wrong score. | |
| # | |
| # `needs: build` (in addition to `route`, for the matrix itself): every | |
| # fixture in this matrix downloads and replays the one shared debug | |
| # `--build-tests` output the `build` job produces instead of each running | |
| # its own independent `swift build --build-tests` from scratch, which is | |
| # what every fixture here used to do (see `build`'s own comment above for | |
| # why, and this file's own history/git blame for the prior per-fixture | |
| # from-scratch build this replaced). There is still no dependency on | |
| # `unit-fast`/`unit-system`'s own results -- only on `build`'s compiled | |
| # output, downloaded fresh by every matrix entry, never on those two jobs | |
| # having run or passed. | |
| # | |
| # `needs: route` + `strategy.matrix: fromJSON(needs.route.outputs.acceptance_matrix)`: | |
| # a REAL dynamic matrix, not a step-level shortcut on a fixed list. The | |
| # `route` job (a few-second classification job, not a rebuild) already | |
| # decided exactly which fixtures this run needs and handed back a | |
| # ready-to-use `{"include": [...]}` matrix object -- see | |
| # Scripts/ci-route.sh's own header comment and | |
| # Scripts/ci-fixtures.json (the single source of truth for every | |
| # fixture's `fixture`/`filter`/`simulator`/`wave` fields, read by that | |
| # script for both the full-matrix and the targeted case). An unselected | |
| # fixture therefore never creates a runner job at all -- it does not | |
| # occupy a runner slot only to skip every step, and a `skipped` acceptance | |
| # entry in the Actions UI now genuinely means "this fixture's steps were | |
| # conditional and didn't run this time" never applies, because no such | |
| # entry exists to begin with. Per-fixture rationale that used to live | |
| # inline on each matrix row now lives on the row's own data in | |
| # Scripts/ci-fixtures.json's header comment plus the notes below, since | |
| # JSON itself has no comment syntax: | |
| # - `cli-commands` is `simulator: "1"`, not "0", despite looking like a | |
| # host-only SwiftPM fixture: `CLICommandsAcceptanceTests | |
| # .initDetectsXcodeDestinationDespiteSchemeAmbiguity` runs a real | |
| # `mutantkit init` against `Fixtures/XcodeProject`, which calls | |
| # `XcodeConfigDetector.detectDestination()` -> a real `xcrun simctl | |
| # list devices available --json`. Classified "0" originally, which | |
| # skipped the "Available simulators" preflight step below (gated on | |
| # `matrix.simulator == '1'`) -- a real CI failure traced this exactly: | |
| # `Detected: ...`/`Multiple schemes found (...)` printed correctly, | |
| # but `Detected destination: ...` never appeared, and the test itself | |
| # reported "failed after 66.165 seconds" -- just past `SimulatorPool`'s | |
| # 60s default timeout on `simctl`, on a runner whose CoreSimulator | |
| # subsystem this fixture's own classification told it not to expect to | |
| # need. | |
| # - `xcode-config-detector` is real-machine `XcodeConfigDetector` | |
| # coverage (a real `simctl` call finding a real destination, including | |
| # against a real ambiguous-scheme project), relocated out of the | |
| # always-run unit suite: these are integration tests, not | |
| # deterministic unit tests. See | |
| # `XcodeConfigDetectorAcceptanceTests.swift`'s own header comment. | |
| # - `xcode-batch-testing`, `xcode-batch-testing-ui-target`, | |
| # `xcode-coverage-selection`, and `xcode-incremental-batch-testing` | |
| # all build the same real `.xcodeproj` through the | |
| # batching/incremental/coverage-selection paths that a 100-mutant | |
| # benchmark against a real external project found broken while every | |
| # unit test (all fakes, no real xcodebuild invocation or `.xctestrun`) | |
| # stayed green. They are the whole reason this job exists rather than | |
| # trusting `unit-fast`/`unit-system` alone. | |
| # - `ror-schemata-compile` is a real `swiftc -typecheck` proof that | |
| # RelationalOperatorReplacementSchemataLowerer's ternary lowering | |
| # type-checks at every eligible operand shape -- the same discipline | |
| # as the batch/incremental fixtures above, applied to a lowerer still | |
| # gated out of `SchemataLowererRegistry.builtIn`. No schemata runtime | |
| # binary involved (see the `ror-schemata-differential` job below for | |
| # that), so it fits this matrix's plain build+test shape. | |
| # - `xcode-wave-early-kill` sets `wave: "1"` because | |
| # `Acceptance.waveEnabled` | |
| # (`Tests/MutantKitTests/Acceptance/AcceptanceWaveSupport.swift`) | |
| # gates `XcodeWaveEarlyKillAcceptanceTests` behind | |
| # `MUTANTKIT_WAVE_ACCEPTANCE`, which no workflow set before this | |
| # fixture existed, so this real, differential, Xcode acceptance gate | |
| # for wave-based early kill had never executed in CI. Reuses the same | |
| # `XcodeProject` fixture the `xcode-project` entry already builds -- | |
| # no new fixture, no new infra, just the missing opt-in. | |
| acceptance: | |
| name: Acceptance (${{ matrix.fixture }}) | |
| runs-on: macos-15 | |
| needs: | |
| - route | |
| - build | |
| # Generous: real xcodebuild/simulator work, legitimately slower than | |
| # `unit-fast`'s own plain unit suite — but still bounded, per the same | |
| # "nothing runs unbounded" reasoning as `unit-system`'s own ceiling above. | |
| timeout-minutes: 30 | |
| strategy: | |
| # Never cancel siblings: which fixtures fail together is a diagnosis. | |
| fail-fast: false | |
| # See this job's own comment above -- `route` already filtered this | |
| # down to exactly the fixtures this run needs (or every fixture, on a | |
| # full run), reading Scripts/ci-fixtures.json as its one source of | |
| # truth. No static `include:` list here anymore. | |
| matrix: ${{ fromJSON(needs.route.outputs.acceptance_matrix) }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| .build/checkouts | |
| .build/repositories | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-spm- | |
| - name: Toolchain | |
| run: swift --version && xcodebuild -version | |
| # The suites pick whichever iPhone this machine actually has rather than | |
| # pinning a model, so this is context for a failure, not a gate. | |
| - name: Available simulators | |
| if: matrix.simulator == '1' | |
| run: xcrun simctl list devices available | grep -i iphone || true | |
| # Built once, by the `build` job above -- see its own comment for why | |
| # this replaces a from-scratch `swift build --build-tests` here. | |
| - name: Download build output | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-tests | |
| - name: Extract build output | |
| shell: bash | |
| run: tar -xzf build-tests.tar.gz | |
| - name: Acceptance | |
| shell: bash | |
| env: | |
| MUTANTKIT_ACCEPTANCE: "1" | |
| MUTANTKIT_ACCEPTANCE_SIMULATOR: ${{ matrix.simulator }} | |
| MUTANTKIT_WAVE_ACCEPTANCE: ${{ matrix.wave }} | |
| run: | | |
| set -o pipefail | |
| swift test --skip-build --filter ${{ matrix.filter }} 2>&1 | tee acceptance.log | |
| test_exit=$? | |
| # A filter matching zero tests must fail this job, not pass it | |
| # silently -- see scripts/assert-tests-ran.sh. A real test failure | |
| # above must still win even if the count check itself passes. | |
| scripts/assert-tests-ran.sh acceptance.log "fixture ${{ matrix.fixture }} (filter ${{ matrix.filter }})" || test_exit=1 | |
| exit $test_exit | |
| - name: Upload acceptance failure logs | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: acceptance-${{ matrix.fixture }}-failure-logs | |
| if-no-files-found: ignore | |
| path: acceptance.log | |
| # Separate from the `acceptance` matrix above because this suite links and | |
| # runs the real schemata runtime binary (SchemataMutationRunner drives a | |
| # genuine `swift build`/`swift test` through it) rather than only | |
| # type-checking lowered source — it needs | |
| # MUTANTKIT_SCHEMATA_RUNTIME_LIB_OVERRIDE pointing at a real built | |
| # `libMutantKitSchemataRuntime.a`, which the matrix's plain build+test | |
| # steps never produce. Runs the exact MutationIDs discovered from one | |
| # fixture through both MutationRunner (isolated) and SchemataMutationRunner | |
| # (schemata, invoked directly — RelationalOperatorReplacementSchemataLowerer | |
| # is still not registered in SchemataLowererRegistry.builtIn) and asserts | |
| # zero disagreement — the real gate before that registration can happen. | |
| # | |
| # `needs: route` / `if:` below: same routing as the `acceptance` matrix -- | |
| # runs on the full matrix, or when the `schemata` targeted group matched a | |
| # changed path (this job has no matrix entry of its own to key an `if:` | |
| # off `matrix.fixture` the way `acceptance` does, hence the separate | |
| # `run_schemata_targeted` output). | |
| ror-schemata-differential: | |
| name: ROR schemata isolated-vs-schemata differential | |
| runs-on: macos-15 | |
| needs: | |
| - route | |
| - build | |
| if: success() && (needs.route.outputs.run_full == 'true' || needs.route.outputs.run_schemata_targeted == 'true') | |
| # Same reasoning as `acceptance`'s own ceiling: real build/link/test | |
| # work, bounded rather than unbounded. | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| .build/checkouts | |
| .build/repositories | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-spm- | |
| - name: Toolchain | |
| run: swift --version && xcodebuild -version | |
| - name: Build the schemata runtime static library | |
| run: swift build -c release --product MutantKitSchemataRuntime | |
| # Built once, by the `build` job above -- see its own comment for why | |
| # this replaces a from-scratch `swift build --build-tests` here. The | |
| # release schemata-runtime build just above is genuinely separate | |
| # (different config, different product) and is unaffected -- it still | |
| # runs here, on this runner, every time. | |
| - name: Download build output | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-tests | |
| - name: Extract build output | |
| shell: bash | |
| run: tar -xzf build-tests.tar.gz | |
| - name: Differential acceptance | |
| shell: bash | |
| env: | |
| MUTANTKIT_ACCEPTANCE: "1" | |
| MUTANTKIT_SCHEMATA_RUNTIME_LIB_OVERRIDE: ${{ github.workspace }}/.build/release | |
| run: | | |
| set -o pipefail | |
| swift test --skip-build --filter RORSchemataIsolatedDifferentialAcceptanceTests 2>&1 | tee acceptance.log | |
| test_exit=$? | |
| # See scripts/assert-tests-ran.sh -- zero matched tests must fail this job. | |
| scripts/assert-tests-ran.sh acceptance.log "RORSchemataIsolatedDifferentialAcceptanceTests" || test_exit=1 | |
| exit $test_exit | |
| - name: Upload failure logs | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ror-schemata-differential-failure-logs | |
| if-no-files-found: ignore | |
| path: acceptance.log | |
| # Separate from both `acceptance` and `ror-schemata-differential` for the same | |
| # reason as the latter: it needs a real, non-default | |
| # MUTANTKIT_SCHEMATA_RUNTIME_LIB_OVERRIDE layout that the plain matrix build+test | |
| # steps never produce — here, BOTH the macOS archive (`swift build`'s normal | |
| # product, needed as this suite's negative control) AND the iOS-Simulator archive | |
| # (produced only by scripts/build-schemata-runtime.sh, which nothing else in CI | |
| # runs) living side by side under one override directory, exactly the shape | |
| # SchemataRuntimeLibraryLocator expects. An earlier draft of this feature had no | |
| # job that actually exercised this combination in CI at all — the new acceptance | |
| # suites existed but nothing wired MUTANTKIT_SCHEMATA_RUNTIME_LIB_OVERRIDE for | |
| # them, so they would have failed closed with `missingOverride` the first time | |
| # anyone ran them here, silently never proving anything past a developer's own | |
| # laptop. This job is that wiring. | |
| # | |
| # `needs: route` / `if:` below: same routing as `ror-schemata-differential` | |
| # above -- full matrix, or the `schemata` targeted group. | |
| ios-simulator-schemata-runtime: | |
| name: iOS-Simulator schemata runtime (build + link viability) | |
| runs-on: macos-15 | |
| needs: | |
| - route | |
| - build | |
| if: success() && (needs.route.outputs.run_full == 'true' || needs.route.outputs.run_schemata_targeted == 'true') | |
| # Same reasoning as `acceptance`'s own ceiling: real build/link/test | |
| # work, bounded rather than unbounded. | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Cache SwiftPM dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| .build/checkouts | |
| .build/repositories | |
| ~/Library/Caches/org.swift.swiftpm | |
| key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-spm- | |
| - name: Toolchain | |
| run: swift --version && xcodebuild -version | |
| - name: Build the macOS schemata runtime static library (this suite's negative control) | |
| run: swift build -c release --product MutantKitSchemataRuntime | |
| - name: Build the iOS-Simulator schemata runtime static library | |
| run: scripts/build-schemata-runtime.sh "$GITHUB_WORKSPACE/.build/release" | |
| # Built once, by the `build` job above -- see its own comment for why | |
| # this replaces a from-scratch `swift build --build-tests` here. The | |
| # two release schemata-runtime builds just above are genuinely | |
| # separate (different config, different products) and are unaffected | |
| # -- they still run here, on this runner, every time. | |
| - name: Download build output | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-tests | |
| - name: Extract build output | |
| shell: bash | |
| run: tar -xzf build-tests.tar.gz | |
| - name: iOS-Simulator runtime acceptance | |
| shell: bash | |
| env: | |
| MUTANTKIT_ACCEPTANCE: "1" | |
| MUTANTKIT_SCHEMATA_RUNTIME_LIB_OVERRIDE: ${{ github.workspace }}/.build/release | |
| run: | | |
| set -o pipefail | |
| swift test --skip-build --filter "SchemataIOSSimulatorRuntimeArtifactAcceptanceTests|SchemataIOSSimulatorRuntimeLinkAcceptanceTests" 2>&1 | tee acceptance.log | |
| test_exit=$? | |
| # See scripts/assert-tests-ran.sh -- zero matched tests must fail this job. | |
| scripts/assert-tests-ran.sh acceptance.log "SchemataIOSSimulatorRuntime*AcceptanceTests" || test_exit=1 | |
| exit $test_exit | |
| - name: Upload failure logs | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ios-simulator-schemata-runtime-failure-logs | |
| if-no-files-found: ignore | |
| path: acceptance.log | |
| # A single, stably-named status check that a future required-status-check | |
| # / merge-queue setup can point at, instead of enumerating every | |
| # individual job above (a list that changes shape as fixtures are added, | |
| # split, or renamed) or every acceptance matrix entry directly (a dynamic | |
| # matrix built from `route`'s own `acceptance_matrix` output -- see the | |
| # `acceptance` job's own comment -- so a routed-away fixture's job simply | |
| # does not exist this run rather than existing and reporting `skipped`; | |
| # `needs.acceptance.result` below already aggregates correctly either | |
| # way). Deciding whether to actually turn on branch protection or a merge | |
| # queue against this check is a repo-admin decision and stays out of this | |
| # workflow's own scope; this job only guarantees the check exists and | |
| # means what its name says. | |
| merge-gate: | |
| # "Merge gate", not "Full merge gate": this check is meant to be the one | |
| # required status for every PR, targeted runs included -- "Full" read as | |
| # if it only applied to (or only passed on) a full-matrix run, which is | |
| # not the design. | |
| name: Merge gate | |
| runs-on: ubuntu-latest | |
| needs: | |
| - lint | |
| - route | |
| - build | |
| - unit-fast | |
| - unit-system | |
| - acceptance | |
| - ror-schemata-differential | |
| - ios-simulator-schemata-runtime | |
| # `always()`, not the default `success()`: this job's entire job is to | |
| # report on every other job's outcome, including a failure or a | |
| # GitHub-initiated cancellation upstream -- the default `if:` would | |
| # instead skip this job itself in exactly that case, leaving the one | |
| # check meant to be authoritative silently absent instead of reporting | |
| # `failure`. | |
| if: always() | |
| steps: | |
| # Fail-closed, not "success|skipped accepted uniformly": that older | |
| # form treated an UNEXPECTED skip (e.g. `lint` or `acceptance` never | |
| # running at all, from a GitHub-side scheduling problem or a future | |
| # accidental `if:` on a job that must always run) exactly the same as | |
| # `route`'s own deliberate, intentional skip of `unit-system`/the | |
| # schemata jobs on a narrowly-targeted run -- both looked like a | |
| # harmless "skipped" to this job, so both passed the gate. This step | |
| # instead derives the actual expected result for every dependency from | |
| # `route`'s own real output before comparing, so this gate proves the | |
| # execution plan `route` declared and what actually happened are the | |
| # same thing, not merely that nothing outright failed. | |
| - name: Confirm every job matches route's declared execution plan | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| run_full="${{ needs.route.outputs.run_full }}" | |
| run_schemata_targeted="${{ needs.route.outputs.run_schemata_targeted }}" | |
| failed="false" | |
| check() { | |
| local name="$1" actual="$2" expected="$3" | |
| echo "$name -> $actual (expected: $expected)" | |
| if [ "$actual" != "$expected" ]; then | |
| echo "::error::$name reported '$actual', expected '$expected' per route's own execution plan (run_full=$run_full, run_schemata_targeted=$run_schemata_targeted)" | |
| failed="true" | |
| fi | |
| } | |
| # Always required to genuinely succeed -- route never skips any of | |
| # these regardless of run_full/run_schemata_targeted, so a skip | |
| # here is never intentional. | |
| check "lint" "${{ needs.lint.result }}" "success" | |
| check "route" "${{ needs.route.result }}" "success" | |
| check "build" "${{ needs.build.result }}" "success" | |
| check "unit-fast" "${{ needs.unit-fast.result }}" "success" | |
| check "acceptance" "${{ needs.acceptance.result }}" "success" | |
| # Gated behind run_full -- route's own `if:` on these jobs. | |
| if [ "$run_full" = "true" ]; then | |
| check "unit-system" "${{ needs.unit-system.result }}" "success" | |
| else | |
| check "unit-system" "${{ needs.unit-system.result }}" "skipped" | |
| fi | |
| # Gated behind run_full OR run_schemata_targeted. | |
| if [ "$run_full" = "true" ] || [ "$run_schemata_targeted" = "true" ]; then | |
| check "ror-schemata-differential" "${{ needs.ror-schemata-differential.result }}" "success" | |
| check "ios-simulator-schemata-runtime" "${{ needs.ios-simulator-schemata-runtime.result }}" "success" | |
| else | |
| check "ror-schemata-differential" "${{ needs.ror-schemata-differential.result }}" "skipped" | |
| check "ios-simulator-schemata-runtime" "${{ needs.ios-simulator-schemata-runtime.result }}" "skipped" | |
| fi | |
| if [ "$failed" = "true" ]; then | |
| echo "::error::merge-gate: at least one job's actual result did not match route's declared execution plan" | |
| exit 1 | |
| fi | |
| echo "merge-gate: every job's result matches route's declared execution plan exactly" |