@plainconceptsplatform/workflows 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,7 +19,7 @@ export interface MandatoryFile {
19
19
  }
20
20
  export declare const mandatoryFiles: readonly MandatoryFile[];
21
21
  export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
22
- export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "dependabot-merge", "feature-request", "github-release", "opencode.ci.json", "platform-template", "quality-gates"];
22
+ export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "dependabot-merge", "feature-request", "github-release", "opencode.ci.json", "platform-template", "quality-gates", "quality-mutation"];
23
23
  export type TemplateName = (typeof templateNames)[number];
24
24
  export interface CatalogTemplate {
25
25
  readonly name: TemplateName;
@@ -62,6 +62,7 @@ export const templateNames = [
62
62
  "opencode.ci.json",
63
63
  "platform-template",
64
64
  "quality-gates",
65
+ "quality-mutation",
65
66
  ];
66
67
  export const catalogTemplates = [
67
68
  { name: "agentics-app-errors", file: "agentics-app-errors.yml", description: "Daily application error report: asks Application Insights what the deployed app threw, groups it, and files one issue per distinct problem HERE, labelled bug + refine so the belt picks it up. Deterministic, no model; the report never leaves this repository, and a leak scanner withholds anything carrying an identifier." },
@@ -76,6 +77,7 @@ export const catalogTemplates = [
76
77
  { name: "opencode.ci.json", file: "opencode.ci.json", description: "Standalone OpenCode CI config: plainconcepts provider, GLM model registration, ci-workflow-agent, and LSP defaults for consumer repositories." },
77
78
  { name: "platform-template", file: "scripts/check-platform-conformance.mjs", directory: "platform-template", bundle: true, description: "Template sync driven by the platform contract (.platform/contract.yml): a weekly workflow that runs its sync.update command and opens or updates one pull request, and a CI guard that keeps its owned paths equal to the template outside a sync. Does nothing without a contract." },
78
79
  { name: "quality-gates", file: "scripts/check-coverage.mjs", directory: "quality-gates", bundle: true, description: "Platform family quality gates: coverage, complexity, duplication and mutation gates, the Engineering Excellence facets, the Semgrep hygiene rules, jscpd and ZAP triage. Floors come from the app's quality.config.json; baselines stay app files." },
80
+ { name: "quality-mutation", file: "quality-mutation.yml", directory: "ci", description: "Weekly out-of-band mutation testing, the Atlas shape: one Stryker.NET job per .NET scope and one StrykerJS web job, each gated on its covered-code floor from quality.config.json, folded into the engineering-excellence-mutation facet. Never on pull requests; App: CI keeps the coverage floor as the per-change gate. Needs the quality-gates template." },
79
81
  ];
80
82
  /** Bundle templates: several package-managed files, refreshed by `update` where installed. */
81
83
  export const bundleTemplateNames = catalogTemplates
@@ -11,6 +11,10 @@
11
11
  # every push and through its own weekly schedule. The platform-conformance job runs the
12
12
  # platform-template guard where the app declares a platform contract.
13
13
  #
14
+ # No mutation testing here, on purpose: a pull request's quality gate is the coverage floor, and
15
+ # Stryker over even one changed page took longer than the rest of CI together. Mutation runs out of
16
+ # band, weekly, in `Quality: Mutation testing (scheduled)` (`workflows add --template quality-mutation`).
17
+ #
14
18
  # Everything that differs between apps is a top-level `env:` value below or a floor in
15
19
  # quality.config.json. Job names are required status checks on main: do not rename them.
16
20
  name: "App: CI"
@@ -279,51 +283,6 @@ jobs:
279
283
  if-no-files-found: ignore
280
284
  retention-days: 7
281
285
 
282
- mutation-api:
283
- name: Mutation (.NET, diff)
284
- if: ${{ (github.event_name == 'pull_request') && needs.depth.outputs.full == 'true' }}
285
- needs: [depth]
286
- runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
287
- timeout-minutes: 25
288
- defaults:
289
- run:
290
- working-directory: ${{ env.API_WORKING_DIRECTORY }}
291
- steps:
292
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
293
- with:
294
- # Stryker --since diffs against the PR base; it needs history, not a shallow tip.
295
- fetch-depth: 0
296
- - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
297
- with:
298
- dotnet-version: ${{ env.DOTNET_VERSION }}
299
- env:
300
- DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
301
- - name: Detect backend changes
302
- id: scope
303
- working-directory: ${{ github.workspace }}
304
- run: |
305
- git fetch origin "$GITHUB_BASE_REF" --depth=1
306
- if git diff --name-only "origin/$GITHUB_BASE_REF" HEAD | grep -qE "^${API_WORKING_DIRECTORY}/"; then
307
- echo "run=true" >> "$GITHUB_OUTPUT"
308
- else
309
- echo "run=false" >> "$GITHUB_OUTPUT"
310
- echo "No ${API_WORKING_DIRECTORY} changes in this PR; skipping .NET mutation."
311
- fi
312
- - name: Restore tools and build
313
- if: steps.scope.outputs.run == 'true'
314
- run: |
315
- dotnet tool restore
316
- dotnet build "$DOTNET_SOLUTION" -c Release
317
- # The project, test projects and thresholds come from the app's stryker-config.json.
318
- - name: Stryker.NET (changed files only)
319
- if: steps.scope.outputs.run == 'true'
320
- run: |
321
- export PATH="$PATH:$HOME/.dotnet/tools"
322
- dotnet stryker \
323
- --since:"origin/$GITHUB_BASE_REF" \
324
- --concurrency 2 \
325
- --reporter progress --reporter json
326
-
327
286
  web:
328
287
  name: Web (Next.js static export)
329
288
  needs: [depth, scope]
@@ -582,65 +541,6 @@ jobs:
582
541
  node scripts/check-platform-conformance.mjs --compare | tee -a "$GITHUB_STEP_SUMMARY"
583
542
  fi
584
543
 
585
- mutation-web:
586
- name: Mutation (web, diff)
587
- if: ${{ (github.event_name == 'pull_request') && needs.depth.outputs.full == 'true' }}
588
- needs: [depth]
589
- runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
590
- timeout-minutes: 60
591
- steps:
592
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
593
- with:
594
- fetch-depth: 0
595
- - name: Detect web changes
596
- id: scope
597
- run: |
598
- git fetch origin "$GITHUB_BASE_REF" --depth=1
599
- if git diff --name-only "origin/$GITHUB_BASE_REF" HEAD | grep -qE "^apps/web/"; then
600
- echo "run=true" >> "$GITHUB_OUTPUT"
601
- else
602
- echo "run=false" >> "$GITHUB_OUTPUT"
603
- echo "No apps/web changes in this PR; skipping web mutation."
604
- fi
605
- - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
606
- if: steps.scope.outputs.run == 'true'
607
- with:
608
- run_install: false
609
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
610
- if: steps.scope.outputs.run == 'true'
611
- with:
612
- node-version: ${{ env.NODE_VERSION }}
613
- cache: pnpm
614
- cache-dependency-path: pnpm-lock.yaml
615
- - name: Configure optional private registry authentication
616
- if: steps.scope.outputs.run == 'true'
617
- env:
618
- NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
619
- run: |
620
- if [ -n "$NPM_REGISTRY_TOKEN" ]; then
621
- echo "${NPM_REGISTRY_URL}:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc
622
- fi
623
- - name: Install and run StrykerJS (changed files only)
624
- if: steps.scope.outputs.run == 'true'
625
- run: |
626
- pnpm install --frozen-lockfile
627
- # StrykerJS v9 removed --since. Compute changed TS/TSX files and pass them to --mutate.
628
- # The config already excludes test files; we only need to scope to changed source files.
629
- changed_files=$(git diff --name-only "origin/$GITHUB_BASE_REF" HEAD -- 'apps/web/src/**/*.ts' 'apps/web/src/**/*.tsx' \
630
- | sed 's|^apps/web/||' \
631
- | grep -v '\.test\.' \
632
- | grep -v '\.spec\.' \
633
- | grep -v '/__mocks__/' \
634
- | paste -sd, -)
635
- if [ -z "$changed_files" ]; then
636
- echo "No changed web source files to mutate; skipping."
637
- exit 0
638
- fi
639
- echo "Mutating: $changed_files"
640
- # StrykerJS v9 child worker cannot auto-discover @stryker-mutator/vitest-runner
641
- # under pnpm hoisting. --appendPlugins forces the plugin import from the workspace.
642
- cd apps/web && pnpm exec stryker run --mutate "$changed_files" --appendPlugins "@stryker-mutator/vitest-runner"
643
-
644
544
  secret-scan:
645
545
  name: Secret scan (TruffleHog, verified only)
646
546
  runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
@@ -731,13 +631,31 @@ jobs:
731
631
  steps:
732
632
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
733
633
  - name: Generate SBOM
734
- uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
634
+ id: sbom
635
+ uses: anchore/sbom-action@fffec3d851af758d56fee099cf3fbf8aa8779843 # v0.24.3
636
+ continue-on-error: true
735
637
  with:
736
638
  format: cyclonedx-json
737
639
  artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
640
+ syft-version: v1.57.0
738
641
  # On a tag that already has a GitHub Release the action attaches the SBOM to it, which
739
642
  # needs contents: write this workflow does not have, and fails the run that gates deploy.
740
643
  upload-release-assets: false
644
+ # The action downloads the syft binary with a single non-retried GET. A transient GitHub
645
+ # release-CDN 504 (log: "received HTTP status=504 ... _checksums.txt") flips this step to
646
+ # failure and, because SBOM gates deploy, blocks PROD for no code reason. The first attempt is
647
+ # continue-on-error so the job stays live; this retry runs only when it actually failed. If
648
+ # BOTH attempts fail (a real syft/SBOM error, not a flaky download), the job still fails and
649
+ # SBOM stays a gate.
650
+ - name: Retry SBOM on transient asset 504
651
+ if: ${{ steps.sbom.outcome == 'failure' }}
652
+ uses: anchore/sbom-action@fffec3d851af758d56fee099cf3fbf8aa8779843 # v0.24.3
653
+ with:
654
+ format: cyclonedx-json
655
+ artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
656
+ syft-version: v1.57.0
657
+ # Same guard as above: a tag release needs contents: write, which this job lacks.
658
+ upload-release-assets: false
741
659
 
742
660
  # Builds the coverage, complexity and hygiene facets (uploaded from main only) and runs the
743
661
  # blocking complexity and duplication gates. The coverage floors stay with the api and web jobs.
@@ -938,7 +856,7 @@ jobs:
938
856
  # The merge train reads this job by its name to tell a full run from a light one: do not rename it.
939
857
  full-ci:
940
858
  name: Full CI
941
- needs: [depth, api, web, e2e, mutation-api, mutation-web, engineering-excellence]
859
+ needs: [depth, api, web, e2e, engineering-excellence]
942
860
  if: ${{ always() && needs.depth.outputs.full == 'true' }}
943
861
  runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
944
862
  timeout-minutes: 5
@@ -968,7 +886,7 @@ jobs:
968
886
  # and the router's maintenance run merges Dependabot updates instead of the train.
969
887
  dispatch-merge-gate:
970
888
  name: Dispatch merge gate
971
- needs: [api, mutation-api, web, mutation-web, secret-scan, deps-and-iac-scan, sast-scan, sbom, engineering-excellence, platform-conformance, full-ci]
889
+ needs: [api, web, secret-scan, deps-and-iac-scan, sast-scan, sbom, engineering-excellence, platform-conformance, full-ci]
972
890
  if: >
973
891
  always() &&
974
892
  vars.AGENTIC_WORKFLOWS_DISABLED == '' &&
@@ -986,7 +904,7 @@ jobs:
986
904
  - name: Hand this run's verdict to the merge train after CI settles
987
905
  env:
988
906
  GH_TOKEN: ${{ github.token }}
989
- # The jobs the apps judged before this template; the mutation runs are only waited for.
907
+ # The jobs the apps judged before this template.
990
908
  RESULTS: >-
991
909
  ${{ needs.api.result }} ${{ needs.web.result }} ${{ needs.secret-scan.result }}
992
910
  ${{ needs.deps-and-iac-scan.result }} ${{ needs.sast-scan.result }} ${{ needs.sbom.result }}
@@ -1,4 +1,7 @@
1
1
  # Managed by @plainconceptsplatform/workflows. Source: loops/templates/ci/app-ci-node-monorepo.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ # No mutation testing here, on purpose: a pull request's quality gate is the test suite, and Stryker
3
+ # over even one changed page took longer than the rest of CI together. Mutation runs out of band,
4
+ # weekly, in `Quality: Mutation testing (scheduled)` (`workflows add --template quality-mutation`).
2
5
  name: "App: CI (Node monorepo)"
3
6
 
4
7
  on:
@@ -27,7 +30,6 @@ env:
27
30
  TRUFFLEHOG_IMAGE: "trufflesecurity/trufflehog:latest"
28
31
  SEMGREP_IMAGE: "semgrep/semgrep:1.168.0"
29
32
  SBOM_ARTIFACT_NAME: app-sbom.cyclonedx.json
30
- MUTATION_THRESHOLD: "60"
31
33
 
32
34
  permissions:
33
35
  contents: read
@@ -72,35 +74,6 @@ jobs:
72
74
  retention-days: 3
73
75
  if-no-files-found: error
74
76
 
75
- mutation-web:
76
- name: Mutation (web, diff)
77
- if: github.event_name == 'pull_request'
78
- runs-on: agents-arc
79
- timeout-minutes: 25
80
- steps:
81
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
82
- with:
83
- fetch-depth: 0
84
- - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
85
- with: { version: "${{ env.PNPM_VERSION }}", run_install: false }
86
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
87
- with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
88
- - name: Detect web changes
89
- id: scope
90
- run: |
91
- git fetch origin "$GITHUB_BASE_REF" --depth=1
92
- if git diff --name-only "origin/$GITHUB_BASE_REF" HEAD | grep -qE "^apps/web/"; then
93
- echo "run=true" >> "$GITHUB_OUTPUT"
94
- else
95
- echo "run=false" >> "$GITHUB_OUTPUT"
96
- echo "No apps/web changes in this PR; skipping web mutation."
97
- fi
98
- - run: pnpm install --frozen-lockfile
99
- if: steps.scope.outputs.run == 'true'
100
- - name: StrykerJS (changed files only)
101
- if: steps.scope.outputs.run == 'true'
102
- run: pnpm --filter "$WEB_PACKAGE" exec stryker run --since="origin/$GITHUB_BASE_REF"
103
-
104
77
  e2e:
105
78
  name: E2E (Playwright)
106
79
  if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
@@ -204,13 +177,32 @@ jobs:
204
177
  runs-on: agents-arc
205
178
  steps:
206
179
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
207
- - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
180
+ - name: Generate SBOM
181
+ id: sbom
182
+ uses: anchore/sbom-action@fffec3d851af758d56fee099cf3fbf8aa8779843 # v0.24.3
183
+ continue-on-error: true
208
184
  with:
209
185
  format: cyclonedx-json
210
186
  artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
187
+ syft-version: v1.57.0
211
188
  # On a tag that already has a GitHub Release the action attaches the SBOM to it, which
212
189
  # needs contents: write this workflow does not have, and fails the run that gates deploy.
213
190
  upload-release-assets: false
191
+ # The action downloads the syft binary with a single non-retried GET. A transient GitHub
192
+ # release-CDN 504 (log: "received HTTP status=504 ... _checksums.txt") flips this step to
193
+ # failure and, because SBOM gates deploy, blocks PROD for no code reason. The first attempt is
194
+ # continue-on-error so the job stays live; this retry runs only when it actually failed. If
195
+ # BOTH attempts fail (a real syft/SBOM error, not a flaky download), the job still fails and
196
+ # SBOM stays a gate.
197
+ - name: Retry SBOM on transient asset 504
198
+ if: ${{ steps.sbom.outcome == 'failure' }}
199
+ uses: anchore/sbom-action@fffec3d851af758d56fee099cf3fbf8aa8779843 # v0.24.3
200
+ with:
201
+ format: cyclonedx-json
202
+ artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
203
+ syft-version: v1.57.0
204
+ # Same guard as above: a tag release needs contents: write, which this job lacks.
205
+ upload-release-assets: false
214
206
 
215
207
  # The merge queue requires this check. On a pull request with MERGE_QUEUE on, the heavy jobs are
216
208
  # skipped and the full CI runs on the merge group instead; skipped counts as passed.
@@ -244,7 +236,7 @@ jobs:
244
236
  # and the router's maintenance run merges Dependabot updates instead of the train.
245
237
  dispatch-merge-train:
246
238
  name: Dispatch merge train
247
- needs: [lint, web, mutation-web, e2e, desktop, mobile]
239
+ needs: [lint, web, e2e, desktop, mobile]
248
240
  if: >
249
241
  always() &&
250
242
  github.event_name == 'pull_request' &&
@@ -0,0 +1,268 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/templates/ci/quality-mutation.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: "Quality: Mutation testing (scheduled)"
3
+
4
+ # Out-of-band mutation run, the shape PlainConceptsPlatform/Atlas runs in
5
+ # .github/workflows/quality-mutation.yml. Deliberately NOT on pull_request: the per-change quality
6
+ # gate is the coverage floor in App: CI, which stays fast. This measures something different,
7
+ # whether the covered lines are actually ASSERTED, and it only needs to move weekly. Stryker over
8
+ # one changed React page took 15 minutes on a pull request (the dry run alone reruns the whole
9
+ # suite), which is why App: CI no longer mutates anything.
10
+ #
11
+ # Each scope is its own job: the .NET scopes in a matrix (Stryker.NET, one project each), the web
12
+ # scope on its own (StrykerJS needs Node, not .NET). Each is gated on its covered-code floor from
13
+ # quality.config.json by scripts/check-mutation.mjs, and a full run on main publishes the
14
+ # engineering-excellence-mutation facet. Both scripts come with the quality-gates template.
15
+ #
16
+ # Everything that differs between apps is a top-level `env:` value below, a floor in
17
+ # quality.config.json, or the app's own Stryker config files.
18
+
19
+ on:
20
+ schedule:
21
+ # Spread the apps over the shared fleet: give each repository its own minute.
22
+ - cron: "0 4 * * 6" # Sat 04:00 UTC
23
+ workflow_dispatch:
24
+ inputs:
25
+ scope:
26
+ description: "Which scope to run: all, web, or one of DOTNET_SCOPES"
27
+ required: false
28
+ default: all
29
+ type: string
30
+
31
+ permissions:
32
+ contents: read
33
+
34
+ concurrency:
35
+ group: quality-mutation
36
+ cancel-in-progress: true
37
+
38
+ env:
39
+ DOTNET_VERSION: "10.0.x"
40
+ NODE_VERSION: "22"
41
+ API_WORKING_DIRECTORY: apps/api
42
+ DOTNET_SOLUTION: App.slnx
43
+ # Space-separated scope=project-directory pairs, relative to API_WORKING_DIRECTORY. Each scope
44
+ # mutates one project with the config at stryker/<scope>.json. Empty runs no .NET scope.
45
+ DOTNET_SCOPES: "domain=src/App.Domain"
46
+ # The web scope's working directory and StrykerJS config. An empty WEB_DIRECTORY runs no web scope.
47
+ WEB_DIRECTORY: apps/web
48
+ WEB_STRYKER_CONFIG: stryker.config.json
49
+ NPM_REGISTRY_URL: "//npm.pkg.github.com/"
50
+
51
+ jobs:
52
+ # A matrix context is not available to a job-level `if`, so the workflow_dispatch selection is
53
+ # resolved here and fed in via fromJSON.
54
+ plan:
55
+ name: Plan scopes
56
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
57
+ timeout-minutes: 5
58
+ outputs:
59
+ scopes: ${{ steps.pick.outputs.scopes }}
60
+ web: ${{ steps.pick.outputs.web }}
61
+ steps:
62
+ - id: pick
63
+ env:
64
+ SCOPE: ${{ inputs.scope || 'all' }}
65
+ run: |
66
+ set -euo pipefail
67
+ known=""
68
+ for pair in $DOTNET_SCOPES; do known="$known ${pair%%=*}"; done
69
+ to_json() { printf '%s\n' "$@" | sed '/^$/d' | jq -R . | jq -cs .; }
70
+ # `web` is a separate flag rather than a matrix entry: that job needs Node, not .NET, and
71
+ # shares none of the setup the backend scopes do.
72
+ # shellcheck disable=SC2086 # $known is a list of scope names.
73
+ case "$SCOPE" in
74
+ all) SCOPES=$(to_json $known); WEB=$([ -n "$WEB_DIRECTORY" ] && echo true || echo false) ;;
75
+ web) SCOPES='[]'; WEB=true ;;
76
+ *)
77
+ grep -qw -- "$SCOPE" <<<"$known" || { echo "unknown scope $SCOPE (known:$known web)" >&2; exit 1; }
78
+ SCOPES=$(to_json "$SCOPE"); WEB=false ;;
79
+ esac
80
+ echo "scopes=$SCOPES" >> "$GITHUB_OUTPUT"
81
+ echo "web=$WEB" >> "$GITHUB_OUTPUT"
82
+ echo "Scopes: $SCOPES, web: $WEB"
83
+
84
+ mutate:
85
+ name: Mutants (${{ matrix.scope }})
86
+ needs: plan
87
+ if: ${{ needs.plan.outputs.scopes != '[]' }}
88
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
89
+ # Atlas measured ~3.5 min per scope locally, but its application scope takes ~90 min on the
90
+ # runner and was cancelled at 90, which left the published report without it. 150 keeps
91
+ # headroom while still stopping a runaway job.
92
+ timeout-minutes: 150
93
+ strategy:
94
+ fail-fast: false
95
+ matrix:
96
+ scope: ${{ fromJSON(needs.plan.outputs.scopes) }}
97
+ defaults:
98
+ run:
99
+ working-directory: ${{ env.API_WORKING_DIRECTORY }}
100
+ steps:
101
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
102
+ # Without DOTNET_INSTALL_DIR setup-dotnet installs into /usr/share/dotnet, which the runner
103
+ # user does not own; GitHub-hosted runners keep the default.
104
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
105
+ env:
106
+ DOTNET_INSTALL_DIR: ${{ runner.environment == 'self-hosted' && format('{0}/dotnet', runner.tool_cache) || '' }}
107
+ with:
108
+ dotnet-version: ${{ env.DOTNET_VERSION }}
109
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
110
+ with:
111
+ node-version: ${{ env.NODE_VERSION }}
112
+
113
+ # RESTORE-ONLY, and deliberately no build cache. Stryker does not sandbox: it swaps mutated
114
+ # assemblies straight into bin/Release. If this job ever saved a build cache App: CI restores,
115
+ # PR CI would test MUTATED binaries and still report green. Do not "optimise" this into one.
116
+ - name: Restore .NET tools
117
+ run: dotnet tool restore
118
+ - name: Restore solution
119
+ run: dotnet restore "$DOTNET_SOLUTION"
120
+
121
+ # Run from the mutated project's own directory, with no `solution` key in the config.
122
+ # Solution mode builds via --property:Platform="Any CPU"; the space splits the argument and
123
+ # MSBuild fails with MSB1008 before a single mutant is generated (Atlas#1531). Per-project
124
+ # mode uses Platform=AnyCPU and works.
125
+ - name: Run Stryker
126
+ env:
127
+ SCOPE: ${{ matrix.scope }}
128
+ run: |
129
+ set -euo pipefail
130
+ export PATH="$PATH:$HOME/.dotnet/tools"
131
+ PROJECT_DIR=""
132
+ for pair in $DOTNET_SCOPES; do
133
+ [ "${pair%%=*}" = "$SCOPE" ] && PROJECT_DIR="${pair#*=}"
134
+ done
135
+ [ -n "$PROJECT_DIR" ] || { echo "unknown scope $SCOPE" >&2; exit 1; }
136
+ root="$GITHUB_WORKSPACE/$API_WORKING_DIRECTORY"
137
+ cd "$PROJECT_DIR"
138
+ dotnet stryker \
139
+ --config-file "$root/stryker/$SCOPE.json" \
140
+ --output "$root/StrykerOutput/$SCOPE" \
141
+ | tee "$root/stryker-$SCOPE.log"
142
+
143
+ # perTest is the difference between minutes and hours: without it every mutant reruns the
144
+ # whole suite. The timeout is what actually stops that from burning a runner, so this stays a
145
+ # warning; its job is to say WHY, in the run where it happened. Stryker prints the mode to
146
+ # stdout and writes no log file, hence the tee above.
147
+ - name: Assert coverage analysis stayed perTest
148
+ if: always()
149
+ env:
150
+ SCOPE: ${{ matrix.scope }}
151
+ run: |
152
+ if ! grep -q "CoverageBasedTest" "stryker-$SCOPE.log" 2>/dev/null; then
153
+ echo "::warning::coverage-analysis may have degraded from perTest for $SCOPE"
154
+ fi
155
+
156
+ # `--output <dir>` writes <dir>/reports/ directly, with no timestamped subdirectory.
157
+ - name: Upload mutation report
158
+ if: always()
159
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
160
+ with:
161
+ name: mutation-${{ matrix.scope }}
162
+ path: ${{ env.API_WORKING_DIRECTORY }}/StrykerOutput/${{ matrix.scope }}/reports/
163
+ if-no-files-found: error
164
+ retention-days: 30
165
+
166
+ # A weekly cron nobody reads is how mutation testing dies everywhere, so the scores go into
167
+ # the run summary whether the gate passes or fails.
168
+ #
169
+ # `set -o pipefail` is what makes this a gate at all. The Actions default shell is `bash -e`,
170
+ # where the exit status of a pipeline is its LAST command, so `node … | tee` reports tee's
171
+ # success and the step passes however low the score is. Do not remove it, and do not drop the
172
+ # `tee`: without pipefail the two cancel out silently.
173
+ - name: Mutation score floor
174
+ if: always()
175
+ working-directory: ${{ github.workspace }}
176
+ env:
177
+ SCOPE: ${{ matrix.scope }}
178
+ run: |
179
+ set -euo pipefail
180
+ node scripts/check-mutation.mjs "$SCOPE" \
181
+ "$API_WORKING_DIRECTORY/StrykerOutput/$SCOPE/reports/mutation-report.json" \
182
+ | tee -a "$GITHUB_STEP_SUMMARY"
183
+
184
+ mutate-web:
185
+ name: Mutants (web)
186
+ needs: plan
187
+ if: ${{ needs.plan.outputs.web == 'true' }}
188
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
189
+ # Atlas: 10 min locally over its allowlist but >45 on a 4-vCPU runner. jsdom rendering is the
190
+ # bottleneck and it does not shrink the way the .NET scopes do. Its first run was killed at 45.
191
+ timeout-minutes: 90
192
+ defaults:
193
+ run:
194
+ working-directory: ${{ env.WEB_DIRECTORY }}
195
+ steps:
196
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
197
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
198
+ with:
199
+ run_install: false
200
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
201
+ with:
202
+ node-version: ${{ env.NODE_VERSION }}
203
+ cache: pnpm
204
+ cache-dependency-path: pnpm-lock.yaml
205
+ - name: Configure optional private registry authentication
206
+ working-directory: ${{ github.workspace }}
207
+ env:
208
+ NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
209
+ run: |
210
+ if [ -n "$NPM_REGISTRY_TOKEN" ]; then
211
+ echo "${NPM_REGISTRY_URL}:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc
212
+ fi
213
+ - name: Install workspace
214
+ working-directory: ${{ github.workspace }}
215
+ run: pnpm install --frozen-lockfile
216
+ - name: Run Stryker
217
+ run: pnpm exec stryker run "$WEB_STRYKER_CONFIG"
218
+ - name: Upload mutation report
219
+ if: always()
220
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
221
+ with:
222
+ name: mutation-web
223
+ path: ${{ env.WEB_DIRECTORY }}/reports/mutation
224
+ if-no-files-found: error
225
+ retention-days: 30
226
+ # See the note on the .NET floor step: without pipefail this reports tee's exit status and
227
+ # gates nothing.
228
+ - name: Mutation score floor
229
+ if: always()
230
+ working-directory: ${{ github.workspace }}
231
+ run: |
232
+ set -euo pipefail
233
+ node scripts/check-mutation.mjs web "$WEB_DIRECTORY/reports/mutation/mutation-report.json" \
234
+ | tee -a "$GITHUB_STEP_SUMMARY"
235
+
236
+ # Folds every scope's report into the `engineering-excellence-mutation` artifact Project-Health
237
+ # collects, with the per-module breakdown. Runs when a floor failed too: a drop is the week most
238
+ # worth recording. Uploads only for a full run on main: a single-scope dispatch would read as a
239
+ # partial week.
240
+ engineering-excellence:
241
+ name: Engineering Excellence (mutation)
242
+ needs: [mutate, mutate-web]
243
+ if: ${{ !cancelled() && (needs.mutate.result != 'skipped' || needs.mutate-web.result != 'skipped') }}
244
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
245
+ timeout-minutes: 10
246
+ steps:
247
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
248
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
249
+ with:
250
+ node-version: ${{ env.NODE_VERSION }}
251
+ # One subdirectory per artifact: reports/mutation-<scope>/mutation-report.json.
252
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
253
+ with:
254
+ pattern: mutation-*
255
+ path: reports
256
+ - name: Build facet
257
+ run: |
258
+ set -euo pipefail
259
+ node scripts/engineering-excellence.mjs mutation --out mutation.json --reports reports \
260
+ | tee -a "$GITHUB_STEP_SUMMARY"
261
+ - name: Upload mutation facet
262
+ if: ${{ github.ref == 'refs/heads/main' && (inputs.scope || 'all') == 'all' }}
263
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
264
+ with:
265
+ name: engineering-excellence-mutation
266
+ path: mutation.json
267
+ if-no-files-found: error
268
+ retention-days: 90
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.16.1",
3
+ "version": "1.17.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",