-
Notifications
You must be signed in to change notification settings - Fork 0
1077 lines (998 loc) · 46.5 KB
/
Copy pathaction-smoke-test.yml
File metadata and controls
1077 lines (998 loc) · 46.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
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"
# No job here writes to the repo, comments on a PR, or creates a release --
# every job only checks out the repo, runs the composite action locally, and
# passes artifacts between jobs in the same run. Flagged by CodeQL as
# missing an explicit token scope; contents: read is the correct minimal
# grant, not a default left unset.
permissions:
contents: read
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
newline-in-input-rejected:
name: "An input value containing a newline fails closed (GITHUB_OUTPUT / workflow-command injection)"
runs-on: macos-15
steps:
- uses: actions/checkout@v4
# `mode: install` on purpose — the newline-rejection step runs
# unconditionally, first, before anything else in the composite,
# regardless of mode. YAML's own double-quoted `\n` escape produces a
# real embedded newline in the resulting input value, the same shape
# a value trying to forge an extra `$GITHUB_OUTPUT` record or a second
# workflow-command annotation would need.
- name: "project-root containing an embedded newline"
id: newline-project-root
continue-on-error: true
uses: ./
with:
mode: install
project-root: "line-one\nproject-root=/hijacked"
- name: Assert it failed closed
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.newline-project-root.outcome }}" != "failure" ]; then
echo "::error::expected a project-root containing a newline to fail closed, got outcome '${{ steps.newline-project-root.outcome }}'" >&2
exit 1
fi
- name: "baseline-scope containing an embedded newline"
id: newline-baseline-scope
continue-on-error: true
uses: ./
with:
mode: install
baseline-scope: "line-one\nbaseline-applied=true"
- name: Assert it failed closed
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.newline-baseline-scope.outcome }}" != "failure" ]; then
echo "::error::expected a baseline-scope containing a newline to fail closed, got outcome '${{ steps.newline-baseline-scope.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 (a spaced project-root, an adversarial baseline-scope)"
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).
#
# The two inputs below split the proof in two, deliberately: a real
# `swift build` has to succeed against whatever `project-root` names,
# so it carries only a realistic (if still unusual) character no
# vulnerable script *and* the real Swift toolchain both tolerate --
# spaces. `baseline-scope` is never used as a filesystem path (it only
# ever feeds a cache key), so it carries the actual command-
# substitution shape a vulnerable script would evaluate for real,
# without needing the whole mutation campaign to survive it too.
#
# Single-quoted in bash so the literal space lands in the directory
# name verbatim.
- name: Stage the fixture under a project-root containing a space
shell: bash
run: |
set -euo pipefail
project_root='adversarial project root with spaces'
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 a spaced project-root and an adversarial baseline-scope"
id: mutantkit
continue-on-error: true
uses: ./mutantkit-src
with:
mode: ci
project-root: 'adversarial project root with spaces'
internal-test-binary-dir: ${{ github.workspace }}/test-binary
baseline-scope: "$(touch ${{ runner.temp }}/mutantkit-injection-marker)"
artifact-name: mutantkit-report-input-hardening
# The security property this job exists to prove — checked
# unconditionally, regardless of whether the run above succeeded, so
# an unrelated failure downstream (a toolchain limitation, a flaky
# runner) can never silently skip the one assertion that actually
# matters here.
- name: Assert no injected command ran (checked regardless of the run's own outcome)
if: always()
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
- name: Assert the run actually succeeded against the spaced project-root as real data
shell: bash
run: |
set -euo pipefail
if [ "${{ steps.mutantkit.outcome }}" != "success" ]; then
echo "::error::expected the run against a spaced project-root to succeed, got outcome '${{ steps.mutantkit.outcome }}'" >&2
exit 1
fi
project_root='adversarial project root with spaces'
test -s "$project_root/report.json" \
|| { echo "::error::the run against a spaced 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 —