@plainconceptsplatform/workflows 1.16.2 → 1.17.1

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' }}
@@ -956,7 +856,7 @@ jobs:
956
856
  # The merge train reads this job by its name to tell a full run from a light one: do not rename it.
957
857
  full-ci:
958
858
  name: Full CI
959
- needs: [depth, api, web, e2e, mutation-api, mutation-web, engineering-excellence]
859
+ needs: [depth, api, web, e2e, engineering-excellence]
960
860
  if: ${{ always() && needs.depth.outputs.full == 'true' }}
961
861
  runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
962
862
  timeout-minutes: 5
@@ -986,7 +886,7 @@ jobs:
986
886
  # and the router's maintenance run merges Dependabot updates instead of the train.
987
887
  dispatch-merge-gate:
988
888
  name: Dispatch merge gate
989
- 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]
990
890
  if: >
991
891
  always() &&
992
892
  vars.AGENTIC_WORKFLOWS_DISABLED == '' &&
@@ -1004,7 +904,7 @@ jobs:
1004
904
  - name: Hand this run's verdict to the merge train after CI settles
1005
905
  env:
1006
906
  GH_TOKEN: ${{ github.token }}
1007
- # The jobs the apps judged before this template; the mutation runs are only waited for.
907
+ # The jobs the apps judged before this template.
1008
908
  RESULTS: >-
1009
909
  ${{ needs.api.result }} ${{ needs.web.result }} ${{ needs.secret-scan.result }}
1010
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')
@@ -263,7 +236,7 @@ jobs:
263
236
  # and the router's maintenance run merges Dependabot updates instead of the train.
264
237
  dispatch-merge-train:
265
238
  name: Dispatch merge train
266
- needs: [lint, web, mutation-web, e2e, desktop, mobile]
239
+ needs: [lint, web, e2e, desktop, mobile]
267
240
  if: >
268
241
  always() &&
269
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
@@ -845,10 +845,19 @@ timeout-minutes: 180
845
845
  the code you changed (web: `pnpm exec vitest related --run <changed files>`; .NET:
846
846
  `dotnet test <the test project> --filter <the test classes>`). Do not run integration,
847
847
  end-to-end, mutation or coverage suites here, and do not run the whole test suite to be safe:
848
- the repository's CI runs all of it on your pull request before anything merges, on a runner
849
- that has the database and the browsers this sandbox lacks. The verification commands above are
850
- the repository's quick check for that reason. A full suite in the agent is the slowest part of
851
- a run and repeats what CI does anyway.
848
+ the repository's CI runs them, on a runner that has the database and the browsers this
849
+ sandbox lacks. The verification commands above are the repository's quick check for that
850
+ reason. A full suite in the agent is the slowest part of a run and repeats what CI does
851
+ anyway.
852
+
853
+ **Not running the end-to-end tests does not make them someone else's.** When your change
854
+ moves, renames or removes something a person clicks or reads (a button, a label, a heading, a
855
+ route, a table), or changes what happens after a click, search the end-to-end tests for it
856
+ (their selectors, the text they assert, the routes they open) and update every test that
857
+ uses it in this pull request. Do this even when the repository runs those tests only at night
858
+ or on a schedule: then nothing on the pull request fails, and the suite breaks the morning
859
+ after the merge with no author attached. Pliny-Bot #1506 was ten journeys broken that way by
860
+ Bot Studio changes that merged without their end-to-end tests.
852
861
 
853
862
  **Run them once, on the final tree.** They are slow: Pliny-Bot's `pnpm verify` is about
854
863
  twenty minutes of work on this runner. `pc-plan-apply` runs them in its verify step, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.16.2",
3
+ "version": "1.17.1",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",