@plainconceptsplatform/workflows 1.10.0 → 1.11.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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/dist/catalog-installation.js +5 -0
  3. package/dist/catalog-listing.js +6 -1
  4. package/dist/index.js +27 -4
  5. package/dist/template-bundles.d.ts +21 -0
  6. package/dist/template-bundles.js +108 -0
  7. package/dist/workflow-catalog.d.ts +5 -1
  8. package/dist/workflow-catalog.js +8 -0
  9. package/loops/actions/report-workflow-errors/action.yml +63 -16
  10. package/loops/actions/report-workflow-errors/report-policy.cjs +89 -5
  11. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +18 -0
  12. package/loops/templates/ci/app-ci-dotnet-next.yml +776 -117
  13. package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +171 -0
  14. package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +178 -0
  15. package/loops/templates/quality-gates/.jscpd.json +36 -0
  16. package/loops/templates/quality-gates/.jscpd.json.md +7 -0
  17. package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
  18. package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
  19. package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
  20. package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
  21. package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
  22. package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
  23. package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
  24. package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
  25. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
  26. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
  27. package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
  28. package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
  29. package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
  30. package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
  31. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
  32. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
  33. package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
  34. package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
  35. package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
  36. package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
  37. package/package.json +2 -2
@@ -1,9 +1,26 @@
1
1
  # Managed by @plainconceptsplatform/workflows. Source: loops/templates/ci/app-ci-dotnet-next.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ # Build, test and security-scan a platform family app (.NET API + Next.js web). One workflow so a
3
+ # change is validated once, by one run, instead of two that fire on almost the same paths.
4
+ #
5
+ # Also the engineering gates from Foundations' ai/ENGINEERING.md, run by the quality-gates template
6
+ # (`workflows add --template quality-gates`): Reqnroll acceptance tests, merged .NET + web coverage
7
+ # floors from quality.config.json, the blocking complexity and duplication gates, the Semgrep
8
+ # empty-catch rules, dead private members as build errors, and the
9
+ # engineering-excellence-{coverage,complexity,security,sbom,hygiene} facets Project-Health
10
+ # collects. The facets upload from main only, which this workflow reaches through `App: Deploy` on
11
+ # every push and through its own weekly schedule. The platform-conformance job runs the
12
+ # platform-template guard where the app has adopted the template.
13
+ #
14
+ # Everything that differs between apps is a top-level `env:` value below or a floor in
15
+ # quality.config.json. Job names are required status checks on main: do not rename them.
2
16
  name: "App: CI"
3
17
 
18
+ # Every pull request runs, so the required security checks always report. The app jobs skip
19
+ # themselves (a skipped job satisfies a required check) when the change touches none of the app
20
+ # paths; see the `scope` job.
4
21
  on:
5
22
  pull_request:
6
- # GitHub's merge queue builds the base plus the pull requests ahead; the full CI runs there.
23
+ # GitHub's merge queue: the full CI runs on the merge group, built on main plus what is ahead.
7
24
  merge_group:
8
25
  schedule:
9
26
  - cron: "0 6 * * 1"
@@ -13,119 +30,260 @@ on:
13
30
  NPM_REGISTRY_TOKEN:
14
31
  required: false
15
32
 
33
+ permissions:
34
+ contents: read
35
+
36
+ concurrency:
37
+ group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
38
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
39
+
16
40
  env:
17
41
  DOTNET_VERSION: "10.0.x"
18
42
  NODE_VERSION: "22"
19
- PNPM_VERSION: "10"
20
43
  API_WORKING_DIRECTORY: apps/api
21
44
  DOTNET_SOLUTION: App.slnx
45
+ # EnforceCodeStyleInBuild runs the IDE rules on build; Directory.Build.props makes IDE0051 and
46
+ # IDE0052 (dead private members, practice 11) errors, so dead private code fails the build.
47
+ DOTNET_BUILD_ARGS: "-p:EnforceCodeStyleInBuild=true"
48
+ # Test projects, relative to API_WORKING_DIRECTORY. An empty value skips that suite.
22
49
  UNIT_TEST_PROJECT: tests/App.UnitTests/App.UnitTests.csproj
23
50
  PARITY_TEST_PROJECT: tests/App.ParityTests/App.ParityTests.csproj
51
+ ARCHITECTURE_TEST_PROJECT: ""
52
+ ACCEPTANCE_TEST_PROJECT: tests/App.AcceptanceTests/App.AcceptanceTests.csproj
24
53
  INTEGRATION_TEST_PROJECT: tests/App.IntegrationTests/App.IntegrationTests.csproj
25
- # Per-PR mutation gate. Stryker mutates only files changed since the PR base (--since),
26
- # so a green unit suite that never actually pins behaviour fails here instead of shipping.
27
- # dotnet-stryker is pinned in the api tools manifest (.config/dotnet-tools.json / dotnet-tools.json).
28
- MUTATION_TARGET_PROJECT: src/App.Domain/App.Domain.csproj
29
- MUTATION_TEST_PROJECT: tests/App.UnitTests/App.UnitTests.csproj
30
- MUTATION_THRESHOLD: "60"
31
54
  COVERAGE_SETTINGS: coverage.runsettings
32
- COVERAGE_CHECK_COMMAND: node ../../scripts/check-coverage.mjs TestResults/merged/Cobertura.xml
33
55
  MIGRATION_PROJECT: src/App.Infrastructure/App.Infrastructure.csproj
34
56
  MIGRATION_STARTUP_PROJECT: src/App.Api/App.Api.csproj
57
+ MIGRATION_CONTEXT: AppDbContext
35
58
  SEED_PROJECT: src/App.Seed
59
+ # The API the E2E job serves the built SPA from.
60
+ API_PROJECT: src/App.Api
61
+ # The SQL Server database the API job migrates, seeds and tests against. Migrations, the seed and
62
+ # the E2E API read its connection string from DATABASE_CONNECTION_ENV; the integration suite from
63
+ # INTEGRATION_CONNECTION_ENV, which may be a test-only name so an ambient ConnectionStrings__*
64
+ # cannot outrank what the fixture sets.
36
65
  PRIMARY_DATABASE: app
37
- DATA_LAYER_DATABASE: numa_dl
38
- # Derived per run rather than written down. The container is ephemeral, bound to a random
39
- # host port and reachable only from this job, so the value protects nothing, but a literal
40
- # password in a config file is a policy finding wherever it sits, and this file is public.
41
- # It has to be a workflow-level expression: service containers receive their environment
42
- # before any step runs, so a value generated in a step would arrive too late.
43
- #
44
- # The same expression is repeated inside the service's `options` above rather than read
45
- # from here, because the `env` context is not available in `jobs.<id>.services` only
46
- # inside that service's own `env:`. Written as ${{ env.SQL_SA_PASSWORD }} there, the health
47
- # check probes with an empty password and the service never reports healthy.
48
- SQL_SA_PASSWORD: "Aa1!${{ github.run_id }}"
66
+ DATABASE_CONNECTION_ENV: ConnectionStrings__App
67
+ INTEGRATION_CONNECTION_ENV: ConnectionStrings__App
68
+ # Space-separated NAME=database pairs: each database is created too, and NAME carries its
69
+ # connection string into the integration suite (for example DataLayer__ConnectionString=app_dl).
70
+ EXTRA_DATABASES: ""
71
+ # Space-separated NAME=value pairs the seed also needs (for example ConnectionStrings__blobs=UseDevelopmentStorage=true).
72
+ SEED_EXTRA_ENV: ""
49
73
  WEB_PACKAGE: "@app/web"
74
+ # Extra commands the web job runs after the build, such as a second package's checks.
75
+ WEB_EXTRA_COMMANDS: ""
76
+ # The root package.json name, so a job can install only the root tools (jscpd, markdownlint).
77
+ ROOT_PACKAGE: app
78
+ MARKDOWN_LINT_COMMAND: "pnpm lint:md"
79
+ # Repository-specific checks for the `Repository checks` job; empty skips its steps.
80
+ REPO_CHECK_COMMANDS: ""
81
+ # Playwright against the SPA served by the API. Empty skips the E2E job.
82
+ E2E_COMMAND: ""
83
+ # Node test files the engineering-excellence job runs beside the gates' own.
84
+ EXTRA_NODE_TESTS: ""
50
85
  NPM_REGISTRY_URL: "//npm.pkg.github.com/"
51
- TRUFFLEHOG_IMAGE: "trufflesecurity/trufflehog:latest"
86
+ # Pulled from GHCR rather than Docker Hub, whose anonymous pull limit failed this step elsewhere.
87
+ TRUFFLEHOG_IMAGE: "ghcr.io/trufflesecurity/trufflehog:latest"
52
88
  SEMGREP_IMAGE: "semgrep/semgrep:1.168.0"
89
+ # Package-managed paths, rewritten on every update, so a local fix cannot hold: findings go upstream.
90
+ SEMGREP_EXCLUDES: ".opencode/plugins .github/actions/capture-evidence"
53
91
  SBOM_ARTIFACT_NAME: app-sbom.cyclonedx.json
54
- MIGRATION_CONTEXT: AppDbContext
92
+ # Derived per run rather than written down. The container is ephemeral, bound to a random host
93
+ # port and reachable only from its job, but a literal password is a policy finding wherever it
94
+ # sits. The services repeat the expression: their `options` cannot read the env context.
95
+ SQL_SA_PASSWORD: "Aa1!${{ github.run_id }}"
55
96
 
56
- permissions:
57
- contents: read
97
+ jobs:
98
+ # Tiered CI (repository variable CI_DEPTH_TIERED): a bot's push runs the light checks only, and
99
+ # the merge train asks for the full run once the pull request is next in line, by labelling it
100
+ # ci-full and re-running this run. The label is read here at run time because a re-run keeps the
101
+ # event payload of the first attempt. People's pull requests, main, tags and schedules run in full.
102
+ depth:
103
+ name: CI depth
104
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
105
+ timeout-minutes: 5
106
+ outputs:
107
+ full: ${{ steps.depth.outputs.full }}
108
+ steps:
109
+ - id: depth
110
+ env:
111
+ GH_TOKEN: ${{ github.token }}
112
+ TIERED: ${{ vars.CI_DEPTH_TIERED }}
113
+ MERGE_QUEUE: ${{ vars.MERGE_QUEUE }}
114
+ EVENT: ${{ github.event_name }}
115
+ AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
116
+ PR: ${{ github.event.pull_request.number }}
117
+ REPO: ${{ github.repository }}
118
+ run: |
119
+ full=true
120
+ if [ "$MERGE_QUEUE" = true ] && [ "$EVENT" = pull_request ]; then
121
+ # The merge queue runs the full CI on the merge group, so every pull request runs light.
122
+ full=false
123
+ elif [ "$TIERED" = true ] && [ "$EVENT" = pull_request ] && [ "$AUTHOR_TYPE" = Bot ]; then
124
+ # Unreadable labels run in full: a light run must never stand in for the tests.
125
+ labels=$(gh api "repos/$REPO/issues/$PR/labels" --jq '.[].name') || labels=ci-full
126
+ grep -qx ci-full <<<"$labels" || full=false
127
+ fi
128
+ echo "full=$full" >> "$GITHUB_OUTPUT"
129
+ echo "CI depth: $([ "$full" = true ] && echo full || echo 'light (the merge train asks for the full run)')"
58
130
 
59
- concurrency:
60
- group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
61
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
131
+ scope:
132
+ name: Detect app changes
133
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
134
+ timeout-minutes: 5
135
+ outputs:
136
+ app: ${{ steps.scope.outputs.app }}
137
+ e2e: ${{ steps.scope.outputs.e2e }}
138
+ steps:
139
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
140
+ with:
141
+ fetch-depth: 0
142
+ # Anything that is not a pull request (push via App: Deploy, dispatch) always runs the app jobs.
143
+ - name: Did this change touch the app?
144
+ id: scope
145
+ env:
146
+ EVENT: ${{ github.event_name }}
147
+ BASE_REF: ${{ github.base_ref }}
148
+ run: |
149
+ set -euo pipefail
150
+ echo "e2e=$([ -n "$E2E_COMMAND" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
151
+ if [ "$EVENT" != "pull_request" ]; then
152
+ echo "app=true" >> "$GITHUB_OUTPUT"
153
+ exit 0
154
+ fi
155
+ git fetch origin "$BASE_REF" --depth=1
156
+ if git diff --name-only "origin/$BASE_REF" HEAD \
157
+ | grep -qE '^(apps/|scripts/|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|quality\.config\.json$|\.github/workflows/app-ci\.yml$)'; then
158
+ echo "app=true" >> "$GITHUB_OUTPUT"
159
+ else
160
+ echo "app=false" >> "$GITHUB_OUTPUT"
161
+ echo "No app, script or manifest changes; the API and web jobs skip."
162
+ fi
62
163
 
63
- jobs:
164
+ # Runs on the weekly schedule as well: App: CI has no push trigger (main reaches it through
165
+ # App: Deploy), so the scheduled run on main is the one where this workflow itself runs the
166
+ # Gherkin suite and the facets, independent of whether a deploy is green.
64
167
  api:
65
- name: API (.NET)
66
- if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
67
- runs-on: agents-arc
68
- timeout-minutes: 30
168
+ name: API (.NET 10)
169
+ needs: [depth, scope]
170
+ if: ${{ (needs.scope.outputs.app == 'true') && needs.depth.outputs.full == 'true' }}
171
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
172
+ timeout-minutes: 45
69
173
  services:
70
174
  sqlserver:
71
175
  image: mcr.microsoft.com/mssql/server:2022-latest
72
176
  env:
73
177
  ACCEPT_EULA: "Y"
74
- MSSQL_SA_PASSWORD: ${{ env.SQL_SA_PASSWORD }}
178
+ MSSQL_SA_PASSWORD: "Aa1!${{ github.run_id }}"
75
179
  ports:
76
- # Ephemeral host port. Four runners share this machine, so a fixed 1433 means the
77
- # second concurrent job fails at Initialize containers with "Docker start fail with
78
- # exit code 1". Steps read the assigned port from the job.services context.
180
+ # Ephemeral host port. Self-hosted runners can share a machine, so a fixed 1433 makes the
181
+ # second concurrent job fail at Initialize containers with "Docker start fail with
182
+ # exit code 1". Steps read the assigned port from the job.services context.
79
183
  - 1433
80
184
  options: >-
81
185
  --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Aa1!${{ github.run_id }}' -Q 'SELECT 1' -C || exit 1"
82
- --health-interval 10s --health-timeout 5s --health-retries 12 --health-start-period 30s
186
+ --health-interval 10s
187
+ --health-timeout 5s
188
+ --health-retries 12
189
+ --health-start-period 30s
83
190
  defaults:
84
191
  run:
85
192
  working-directory: ${{ env.API_WORKING_DIRECTORY }}
86
193
  steps:
87
194
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
195
+ # Without DOTNET_INSTALL_DIR setup-dotnet installs into /usr/share/dotnet, which the runner
196
+ # user does not own, and the job dies with "Permission denied" before any step runs.
88
197
  - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
89
198
  with:
90
199
  dotnet-version: ${{ env.DOTNET_VERSION }}
91
- - name: Restore tools, solution, and build
200
+ env:
201
+ DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
202
+ - name: Restore .NET tools
203
+ run: dotnet tool restore
204
+ - name: Restore solution
205
+ run: dotnet restore "$DOTNET_SOLUTION"
206
+ - name: Build (Release)
92
207
  run: |
93
- dotnet tool restore
94
- dotnet restore "$DOTNET_SOLUTION"
95
- dotnet build "$DOTNET_SOLUTION" --no-restore -c Release
96
- - name: Unit tests with coverage
97
- run: dotnet test "$UNIT_TEST_PROJECT" --no-build -c Release --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
208
+ # shellcheck disable=SC2086 # DOTNET_BUILD_ARGS is a list of arguments.
209
+ dotnet build "$DOTNET_SOLUTION" --no-restore -c Release $DOTNET_BUILD_ARGS
210
+ - name: Unit tests + coverage
211
+ if: env.UNIT_TEST_PROJECT != ''
212
+ run: dotnet test "$UNIT_TEST_PROJECT" --no-build -c Release
213
+ --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
98
214
  - name: Parity tests
215
+ if: env.PARITY_TEST_PROJECT != ''
99
216
  run: dotnet test "$PARITY_TEST_PROJECT" --no-build -c Release
217
+ - name: Architecture tests
218
+ if: env.ARCHITECTURE_TEST_PROJECT != ''
219
+ run: dotnet test "$ARCHITECTURE_TEST_PROJECT" --no-build -c Release
220
+ # Executable OpenSpec scenarios (Reqnroll). Each scenario is bound to a spec scenario by tag
221
+ # and fails if that scenario disappears from openspec/.
222
+ - name: Acceptance tests (Reqnroll) + coverage
223
+ if: env.ACCEPTANCE_TEST_PROJECT != ''
224
+ run: dotnet test "$ACCEPTANCE_TEST_PROJECT" --no-build -c Release
225
+ --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
226
+ # APP_SQL_SERVER is the connection string without a database; each later step names its own.
100
227
  - name: Create test databases
228
+ env:
229
+ SQL_CONTAINER: ${{ job.services.sqlserver.id }}
230
+ SQL_PORT: ${{ job.services.sqlserver.ports['1433'] }}
101
231
  run: |
102
- for database in "$PRIMARY_DATABASE" "$DATA_LAYER_DATABASE"; do
103
- docker exec ${{ job.services.sqlserver.id }} /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SQL_SA_PASSWORD" -C -Q "IF DB_ID('$database') IS NULL CREATE DATABASE [$database];"
232
+ set -euo pipefail
233
+ databases="$PRIMARY_DATABASE"
234
+ for pair in $EXTRA_DATABASES; do databases="$databases ${pair#*=}"; done
235
+ for database in $databases; do
236
+ docker exec "$SQL_CONTAINER" /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SQL_SA_PASSWORD" -C \
237
+ -Q "IF DB_ID('$database') IS NULL CREATE DATABASE [$database];"
104
238
  done
105
- - name: Apply migrations and seed test data
106
- env:
107
- ConnectionStrings__App: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.PRIMARY_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
108
- DataLayer__ConnectionString: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.DATA_LAYER_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
109
- DATALAYER_SEED: "true"
239
+ echo "APP_SQL_SERVER=Server=localhost,$SQL_PORT;User Id=sa;Password=$SQL_SA_PASSWORD;TrustServerCertificate=true" >> "$GITHUB_ENV"
240
+ - name: Apply EF Core migrations
110
241
  run: |
242
+ set -euo pipefail
243
+ export "$DATABASE_CONNECTION_ENV=$APP_SQL_SERVER;Database=$PRIMARY_DATABASE"
111
244
  dotnet ef database update -p "$MIGRATION_PROJECT" -s "$MIGRATION_STARTUP_PROJECT" --context "$MIGRATION_CONTEXT"
245
+ - name: Seed minimum dataset for tests
246
+ run: |
247
+ set -euo pipefail
248
+ export "$DATABASE_CONNECTION_ENV=$APP_SQL_SERVER;Database=$PRIMARY_DATABASE"
249
+ for pair in $SEED_EXTRA_ENV; do export "${pair?}"; done
112
250
  dotnet run --project "$SEED_PROJECT" --no-build -c Release
113
- - name: Integration tests with coverage
114
- env:
115
- ConnectionStrings__App: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.PRIMARY_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
116
- DataLayer__ConnectionString: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.DATA_LAYER_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
117
- run: dotnet test "$INTEGRATION_TEST_PROJECT" --no-build -c Release --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
118
- - name: Enforce merged coverage floor
251
+ - name: Integration tests + coverage
252
+ if: env.INTEGRATION_TEST_PROJECT != ''
253
+ run: |
254
+ set -euo pipefail
255
+ export "$INTEGRATION_CONNECTION_ENV=$APP_SQL_SERVER;Database=$PRIMARY_DATABASE"
256
+ for pair in $EXTRA_DATABASES; do export "${pair%%=*}=$APP_SQL_SERVER;Database=${pair#*=}"; done
257
+ dotnet test "$INTEGRATION_TEST_PROJECT" --no-build -c Release \
258
+ --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
259
+ # The coverage step runs a small node script; fresh runner VMs ship no node.
260
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
261
+ with:
262
+ node-version: ${{ env.NODE_VERSION }}
263
+ # pipefail: without it the step reports tee's status and passes however low coverage is.
264
+ - name: Coverage floor (merged)
119
265
  run: |
120
- dotnet tool install --global dotnet-reportgenerator-globaltool
266
+ set -euo pipefail
267
+ dotnet tool install --global dotnet-reportgenerator-globaltool >/dev/null
121
268
  export PATH="$PATH:$HOME/.dotnet/tools" # ephemeral ARC runners do not have dotnet tools on PATH
122
269
  reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:TestResults/merged -reporttypes:Cobertura
123
- bash -c "$COVERAGE_CHECK_COMMAND"
270
+ node "$GITHUB_WORKSPACE/scripts/check-coverage.mjs" TestResults/merged/Cobertura.xml | tee -a "$GITHUB_STEP_SUMMARY"
271
+ # Feeds the engineering-excellence job. Uploaded even when the floor fails, so a drop is
272
+ # recorded rather than lost. Seven days so the complexity baseline can be re-seeded from it.
273
+ - name: Upload merged coverage
274
+ if: ${{ !cancelled() }}
275
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
276
+ with:
277
+ name: api-coverage-merged
278
+ path: ${{ env.API_WORKING_DIRECTORY }}/TestResults/merged/Cobertura.xml
279
+ if-no-files-found: ignore
280
+ retention-days: 7
124
281
 
125
282
  mutation-api:
126
283
  name: Mutation (.NET, diff)
127
- if: github.event_name == 'pull_request'
128
- runs-on: agents-arc
284
+ if: ${{ (github.event_name == 'pull_request') && needs.depth.outputs.full == 'true' }}
285
+ needs: [depth]
286
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
129
287
  timeout-minutes: 25
130
288
  defaults:
131
289
  run:
@@ -138,6 +296,8 @@ jobs:
138
296
  - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
139
297
  with:
140
298
  dotnet-version: ${{ env.DOTNET_VERSION }}
299
+ env:
300
+ DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
141
301
  - name: Detect backend changes
142
302
  id: scope
143
303
  working-directory: ${{ github.workspace }}
@@ -154,69 +314,282 @@ jobs:
154
314
  run: |
155
315
  dotnet tool restore
156
316
  dotnet build "$DOTNET_SOLUTION" -c Release
317
+ # The project, test projects and thresholds come from the app's stryker-config.json.
157
318
  - name: Stryker.NET (changed files only)
158
319
  if: steps.scope.outputs.run == 'true'
159
320
  run: |
160
- export PATH="$PATH:$HOME/.dotnet/tools" # ephemeral ARC runners do not have dotnet tools on PATH
321
+ export PATH="$PATH:$HOME/.dotnet/tools"
161
322
  dotnet stryker \
162
- --project "$MUTATION_TARGET_PROJECT" \
163
- --test-projects "$MUTATION_TEST_PROJECT" \
164
323
  --since:"origin/$GITHUB_BASE_REF" \
165
324
  --concurrency 2 \
166
- --threshold-high "$MUTATION_THRESHOLD" \
167
- --threshold-low "$MUTATION_THRESHOLD" \
168
- --break-at "$MUTATION_THRESHOLD" \
169
325
  --reporter progress --reporter json
170
326
 
171
327
  web:
172
- name: Web (Next.js)
173
- if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
174
- runs-on: agents-arc
328
+ name: Web (Next.js static export)
329
+ needs: [depth, scope]
330
+ if: ${{ (needs.scope.outputs.app == 'true') && needs.depth.outputs.full == 'true' }}
331
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
175
332
  timeout-minutes: 15
176
333
  steps:
177
334
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
335
+ - name: Detect apps/web
336
+ id: detect
337
+ run: |
338
+ if [ -f apps/web/package.json ]; then
339
+ echo "exists=true" >> "$GITHUB_OUTPUT"
340
+ else
341
+ echo "exists=false" >> "$GITHUB_OUTPUT"
342
+ echo "apps/web not present yet, skipping web checks"
343
+ fi
178
344
  - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
345
+ if: steps.detect.outputs.exists == 'true'
179
346
  with:
180
- version: ${{ env.PNPM_VERSION }}
181
347
  run_install: false
182
348
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
349
+ if: steps.detect.outputs.exists == 'true'
183
350
  with:
184
351
  node-version: ${{ env.NODE_VERSION }}
185
352
  cache: pnpm
186
353
  cache-dependency-path: pnpm-lock.yaml
187
354
  - name: Configure optional private registry authentication
355
+ if: steps.detect.outputs.exists == 'true'
188
356
  env:
189
357
  NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
190
358
  run: |
191
359
  if [ -n "$NPM_REGISTRY_TOKEN" ]; then
192
360
  echo "${NPM_REGISTRY_URL}:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc
193
361
  fi
194
- - run: pnpm install --frozen-lockfile
195
- - name: Lint, test, verify boundaries, and build
362
+ - name: Install (pnpm)
363
+ if: steps.detect.outputs.exists == 'true'
364
+ run: pnpm install --frozen-lockfile
365
+ - name: Lint
366
+ if: steps.detect.outputs.exists == 'true'
367
+ run: pnpm --filter "$WEB_PACKAGE" run lint
368
+ - name: Typecheck
369
+ # `next build` transpiles per-file with SWC and never type-checks the project, so a
370
+ # type error outside the build graph, test files especially, ships silently.
371
+ if: steps.detect.outputs.exists == 'true'
372
+ run: pnpm --filter "$WEB_PACKAGE" run typecheck
373
+ - name: Test (vitest + coverage)
374
+ if: steps.detect.outputs.exists == 'true'
375
+ run: pnpm --filter "$WEB_PACKAGE" run test --coverage
376
+ - name: Coverage floor (web)
377
+ if: steps.detect.outputs.exists == 'true'
196
378
  run: |
197
- pnpm --filter "$WEB_PACKAGE" run lint
198
- pnpm --filter "$WEB_PACKAGE" run test
199
- pnpm --filter "$WEB_PACKAGE" run lint:fsd
200
- pnpm --filter "$WEB_PACKAGE" run build
379
+ set -euo pipefail
380
+ node scripts/check-coverage.mjs --web apps/web/coverage/cobertura-coverage.xml | tee -a "$GITHUB_STEP_SUMMARY"
381
+ # Cyclomatic complexity per function, for the complexity gate below. ESLint runs with the
382
+ # `complexity` rule only (apps/web/eslint.complexity.config.mjs); Biome stays the linter.
383
+ - name: Complexity report (ESLint)
384
+ if: ${{ !cancelled() && steps.detect.outputs.exists == 'true' }}
385
+ run: pnpm --filter "$WEB_PACKAGE" run complexity
386
+ - name: Upload web coverage
387
+ if: ${{ !cancelled() && steps.detect.outputs.exists == 'true' }}
388
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
389
+ with:
390
+ name: web-coverage
391
+ path: |
392
+ apps/web/coverage/cobertura-coverage.xml
393
+ apps/web/coverage/coverage-final.json
394
+ apps/web/eslint-complexity.json
395
+ if-no-files-found: ignore
396
+ retention-days: 7
397
+ - name: FSD boundary check (Steiger)
398
+ if: steps.detect.outputs.exists == 'true'
399
+ run: pnpm --filter "$WEB_PACKAGE" run lint:fsd
400
+ # Key parity between locales and no user-facing string literals in JSX (zero magic strings).
401
+ - name: i18n check
402
+ if: steps.detect.outputs.exists == 'true'
403
+ run: pnpm --filter "$WEB_PACKAGE" run --if-present lint:i18n
404
+ - name: Build (Next.js static export)
405
+ if: steps.detect.outputs.exists == 'true'
406
+ run: pnpm --filter "$WEB_PACKAGE" run build
407
+ - name: App-specific web checks
408
+ if: steps.detect.outputs.exists == 'true' && env.WEB_EXTRA_COMMANDS != ''
409
+ run: bash -euo pipefail -c "$WEB_EXTRA_COMMANDS"
201
410
 
202
- mutation-web:
203
- name: Mutation (web, diff)
204
- if: github.event_name == 'pull_request'
205
- runs-on: agents-arc
206
- timeout-minutes: 25
411
+ # The SPA built as production builds it, served by the API from wwwroot (same origin), with mock
412
+ # sign-in and a real SQL Server. Development environment so the API migrates and seeds on start.
413
+ e2e:
414
+ name: E2E (Playwright)
415
+ needs: [depth, scope]
416
+ if: ${{ (needs.scope.outputs.app == 'true') && needs.scope.outputs.e2e == 'true' && needs.depth.outputs.full == 'true' }}
417
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
418
+ timeout-minutes: 30
419
+ services:
420
+ sqlserver:
421
+ image: mcr.microsoft.com/mssql/server:2022-latest
422
+ env:
423
+ ACCEPT_EULA: "Y"
424
+ MSSQL_SA_PASSWORD: "Aa1!${{ github.run_id }}"
425
+ ports:
426
+ - 1433
427
+ options: >-
428
+ --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Aa1!${{ github.run_id }}' -Q 'SELECT 1' -C || exit 1"
429
+ --health-interval 10s
430
+ --health-timeout 5s
431
+ --health-retries 12
432
+ --health-start-period 30s
433
+ env:
434
+ ASPNETCORE_ENVIRONMENT: Development
435
+ ASPNETCORE_URLS: http://localhost:5089
436
+ Auth__Provider: mock
437
+ E2E_BASE_URL: http://localhost:5089
207
438
  steps:
208
439
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
440
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
209
441
  with:
210
- fetch-depth: 0
442
+ dotnet-version: ${{ env.DOTNET_VERSION }}
443
+ env:
444
+ DOTNET_INSTALL_DIR: ${{ runner.tool_cache }}/dotnet
445
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
446
+ with:
447
+ run_install: false
448
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
449
+ with:
450
+ node-version: ${{ env.NODE_VERSION }}
451
+ cache: pnpm
452
+ cache-dependency-path: pnpm-lock.yaml
453
+ - name: Configure optional private registry authentication
454
+ env:
455
+ NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
456
+ run: |
457
+ if [ -n "$NPM_REGISTRY_TOKEN" ]; then
458
+ echo "${NPM_REGISTRY_URL}:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc
459
+ fi
460
+ - name: Install (pnpm)
461
+ run: pnpm install --frozen-lockfile
462
+ - name: Build the SPA into the API's wwwroot
463
+ run: |
464
+ set -euo pipefail
465
+ api="$API_WORKING_DIRECTORY/$API_PROJECT"
466
+ NEXT_PUBLIC_API_BASE_URL=/api/v1 pnpm --filter "$WEB_PACKAGE" run build
467
+ rm -rf "$api/wwwroot"
468
+ cp -r apps/web/out "$api/wwwroot"
469
+ - name: Start the API
470
+ env:
471
+ SQL_PORT: ${{ job.services.sqlserver.ports['1433'] }}
472
+ run: |
473
+ set -euo pipefail
474
+ api="$API_WORKING_DIRECTORY/$API_PROJECT"
475
+ export "$DATABASE_CONNECTION_ENV=Server=localhost,$SQL_PORT;Database=$PRIMARY_DATABASE;User Id=sa;Password=$SQL_SA_PASSWORD;TrustServerCertificate=true"
476
+ dotnet build "$api" -c Release
477
+ nohup dotnet run --project "$api" -c Release --no-build > api.log 2>&1 &
478
+ for _ in $(seq 1 60); do
479
+ if curl -fsS http://localhost:5089/api/v1/health >/dev/null; then exit 0; fi
480
+ sleep 2
481
+ done
482
+ cat api.log
483
+ exit 1
484
+ - name: Install Playwright browsers
485
+ run: |
486
+ set -euo pipefail
487
+ # --with-deps runs apt-get, which needs passwordless sudo. The self-hosted fleet already has
488
+ # the system libraries and does not grant sudo, so ask for them only where sudo works.
489
+ if sudo -n true 2>/dev/null; then
490
+ pnpm --filter "$WEB_PACKAGE" exec playwright install --with-deps chromium
491
+ else
492
+ pnpm --filter "$WEB_PACKAGE" exec playwright install chromium
493
+ fi
494
+ - name: Playwright
495
+ run: bash -euo pipefail -c "$E2E_COMMAND"
496
+ - name: Upload Playwright report and API log
497
+ if: ${{ !cancelled() }}
498
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
499
+ with:
500
+ name: e2e-report
501
+ path: |
502
+ apps/web/playwright-report
503
+ apps/web/test-results
504
+ api.log
505
+ if-no-files-found: ignore
506
+ retention-days: 7
507
+
508
+ # Foundations' one Markdown rule: a paragraph is one physical line (no-hard-wrapped-prose).
509
+ markdown:
510
+ name: Markdown
511
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
512
+ timeout-minutes: 5
513
+ steps:
514
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
515
+ if: env.MARKDOWN_LINT_COMMAND != ''
211
516
  - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
517
+ if: env.MARKDOWN_LINT_COMMAND != ''
212
518
  with:
213
- version: ${{ env.PNPM_VERSION }}
214
519
  run_install: false
215
520
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
521
+ if: env.MARKDOWN_LINT_COMMAND != ''
216
522
  with:
217
523
  node-version: ${{ env.NODE_VERSION }}
218
524
  cache: pnpm
219
525
  cache-dependency-path: pnpm-lock.yaml
526
+ - name: Install root tools only
527
+ if: env.MARKDOWN_LINT_COMMAND != ''
528
+ run: pnpm install --frozen-lockfile --filter "$ROOT_PACKAGE" --ignore-scripts
529
+ - name: Lint Markdown
530
+ if: env.MARKDOWN_LINT_COMMAND != ''
531
+ run: bash -euo pipefail -c "$MARKDOWN_LINT_COMMAND"
532
+
533
+ # Checks only this repository has, from REPO_CHECK_COMMANDS. Node is set up; nothing is installed.
534
+ repo-checks:
535
+ name: Repository checks
536
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
537
+ timeout-minutes: 5
538
+ steps:
539
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
540
+ if: env.REPO_CHECK_COMMANDS != ''
541
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
542
+ if: env.REPO_CHECK_COMMANDS != ''
543
+ with:
544
+ node-version: ${{ env.NODE_VERSION }}
545
+ - name: Repository checks
546
+ if: env.REPO_CHECK_COMMANDS != ''
547
+ run: bash -euo pipefail -c "$REPO_CHECK_COMMANDS"
548
+
549
+ # Platform files (.platform/manifest) change only through a template sync. The guard comes with the
550
+ # platform-template template; a repository without .copier-answers.yml has not adopted it and passes.
551
+ platform-conformance:
552
+ name: Platform conformance
553
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
554
+ timeout-minutes: 5
555
+ steps:
556
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
557
+ with:
558
+ fetch-depth: 0
559
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
560
+ with:
561
+ node-version: ${{ env.NODE_VERSION }}
562
+ - name: Platform files change only through a template sync
563
+ env:
564
+ EVENT: ${{ github.event_name }}
565
+ BASE_REF: ${{ github.base_ref }}
566
+ run: |
567
+ set -euo pipefail
568
+ if [ ! -f .copier-answers.yml ]; then
569
+ echo "No .copier-answers.yml: this repository has not adopted the platform template."
570
+ exit 0
571
+ fi
572
+ if [ ! -f scripts/check-platform-conformance.mjs ]; then
573
+ echo "::error::.copier-answers.yml is here but scripts/check-platform-conformance.mjs is not. Install it with: npx @plainconceptsplatform/workflows add --template platform-template"
574
+ exit 1
575
+ fi
576
+ if [ "$EVENT" = pull_request ]; then
577
+ git fetch origin "$BASE_REF"
578
+ node scripts/check-platform-conformance.mjs --base "origin/$BASE_REF" | tee -a "$GITHUB_STEP_SUMMARY"
579
+ else
580
+ node scripts/check-platform-conformance.mjs | tee -a "$GITHUB_STEP_SUMMARY"
581
+ fi
582
+
583
+ mutation-web:
584
+ name: Mutation (web, diff)
585
+ if: ${{ (github.event_name == 'pull_request') && needs.depth.outputs.full == 'true' }}
586
+ needs: [depth]
587
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
588
+ timeout-minutes: 60
589
+ steps:
590
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
591
+ with:
592
+ fetch-depth: 0
220
593
  - name: Detect web changes
221
594
  id: scope
222
595
  run: |
@@ -227,6 +600,16 @@ jobs:
227
600
  echo "run=false" >> "$GITHUB_OUTPUT"
228
601
  echo "No apps/web changes in this PR; skipping web mutation."
229
602
  fi
603
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
604
+ if: steps.scope.outputs.run == 'true'
605
+ with:
606
+ run_install: false
607
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
608
+ if: steps.scope.outputs.run == 'true'
609
+ with:
610
+ node-version: ${{ env.NODE_VERSION }}
611
+ cache: pnpm
612
+ cache-dependency-path: pnpm-lock.yaml
230
613
  - name: Configure optional private registry authentication
231
614
  if: steps.scope.outputs.run == 'true'
232
615
  env:
@@ -239,21 +622,45 @@ jobs:
239
622
  if: steps.scope.outputs.run == 'true'
240
623
  run: |
241
624
  pnpm install --frozen-lockfile
242
- pnpm --filter "$WEB_PACKAGE" exec stryker run --since="origin/$GITHUB_BASE_REF"
625
+ # StrykerJS v9 removed --since. Compute changed TS/TSX files and pass them to --mutate.
626
+ # The config already excludes test files; we only need to scope to changed source files.
627
+ changed_files=$(git diff --name-only "origin/$GITHUB_BASE_REF" HEAD -- 'apps/web/src/**/*.ts' 'apps/web/src/**/*.tsx' \
628
+ | sed 's|^apps/web/||' \
629
+ | grep -v '\.test\.' \
630
+ | grep -v '\.spec\.' \
631
+ | grep -v '/__mocks__/' \
632
+ | paste -sd, -)
633
+ if [ -z "$changed_files" ]; then
634
+ echo "No changed web source files to mutate; skipping."
635
+ exit 0
636
+ fi
637
+ echo "Mutating: $changed_files"
638
+ # StrykerJS v9 child worker cannot auto-discover @stryker-mutator/vitest-runner
639
+ # under pnpm hoisting. --appendPlugins forces the plugin import from the workspace.
640
+ cd apps/web && pnpm exec stryker run --mutate "$changed_files" --appendPlugins "@stryker-mutator/vitest-runner"
243
641
 
244
642
  secret-scan:
245
- name: Secret scan (TruffleHog)
246
- runs-on: agents-arc
643
+ name: Secret scan (TruffleHog, verified only)
644
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
247
645
  steps:
248
646
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
249
- - run: docker run --rm -v "${{ github.workspace }}:/repo" "$TRUFFLEHOG_IMAGE" filesystem /repo --only-verified --fail --no-update
647
+ - name: TruffleHog filesystem scan
648
+ env:
649
+ WORKSPACE: ${{ github.workspace }}
650
+ run: |
651
+ docker run --rm -v "$WORKSPACE:/repo" "$TRUFFLEHOG_IMAGE" \
652
+ filesystem /repo --only-verified --fail --no-update
250
653
 
654
+ # vuln: pnpm-lock.yaml and the .NET package props. misconfig: infra and the Dockerfile. secret:
655
+ # the whole tree. CRITICAL/HIGH only, unfixed advisories ignored so an unpatched upstream CVE
656
+ # cannot block every PR.
251
657
  deps-and-iac-scan:
252
- name: Dependencies and IaC (Trivy)
253
- runs-on: agents-arc
658
+ name: Dependencies & IaC (Trivy, blocking)
659
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
254
660
  steps:
255
661
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
256
- - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
662
+ - name: Trivy filesystem scan
663
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
257
664
  with:
258
665
  scan-type: fs
259
666
  scanners: vuln,misconfig,secret
@@ -262,19 +669,67 @@ jobs:
262
669
  exit-code: "1"
263
670
  format: table
264
671
 
672
+ # Machine-readable Trivy result for the security facet: every severity, unfixed included, never
673
+ # failing. A job of its own so nothing in this report-only run can leak into the gate above.
674
+ deps-and-iac-report:
675
+ name: Dependencies & IaC report (Trivy JSON, report-only)
676
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
677
+ steps:
678
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
679
+ - name: Trivy filesystem scan (JSON)
680
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
681
+ with:
682
+ scan-type: fs
683
+ scanners: vuln,misconfig,secret
684
+ severity: CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN
685
+ exit-code: "0"
686
+ format: json
687
+ output: trivy.json
688
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
689
+ with:
690
+ name: trivy-json
691
+ path: trivy.json
692
+ if-no-files-found: error
693
+ retention-days: 1
694
+
265
695
  sast-scan:
266
- name: SAST (Semgrep)
267
- runs-on: agents-arc
696
+ name: SAST (Semgrep OSS, blocking)
697
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
268
698
  steps:
269
699
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
270
- - run: docker run --rm -v "${{ github.workspace }}:/src" -w /src "$SEMGREP_IMAGE" semgrep scan --error --metrics=off --oss-only --disable-version-check --config p/default --config p/security-audit --config p/secrets --config p/csharp --config p/typescript --config p/react --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
700
+ # --json-output writes the same findings as the blocking text output, for the security facet.
701
+ # `--config .semgrep/` adds the hygiene rules (empty catch blocks, practice 11) from the
702
+ # quality-gates template; their findings also feed the hygiene facet.
703
+ - name: Semgrep scan (blocking)
704
+ env:
705
+ WORKSPACE: ${{ github.workspace }}
706
+ run: |
707
+ set -euo pipefail
708
+ excludes=()
709
+ for path in $SEMGREP_EXCLUDES; do excludes+=(--exclude "$path"); done
710
+ docker run --rm -v "$WORKSPACE:/src" -w /src "$SEMGREP_IMAGE" \
711
+ semgrep scan --error --metrics=off --oss-only --disable-version-check \
712
+ --config p/default --config p/security-audit --config p/secrets \
713
+ --config p/csharp --config p/typescript --config p/react --config .semgrep/ \
714
+ --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag \
715
+ "${excludes[@]}" \
716
+ --json-output=semgrep.json
717
+ # Uploaded when the scan fails too: a failing run is exactly the one worth recording.
718
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
719
+ if: ${{ !cancelled() }}
720
+ with:
721
+ name: semgrep-json
722
+ path: semgrep.json
723
+ if-no-files-found: ignore
724
+ retention-days: 1
271
725
 
272
726
  sbom:
273
- name: SBOM (CycloneDX)
274
- runs-on: agents-arc
727
+ name: SBOM (Syft, CycloneDX)
728
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
275
729
  steps:
276
730
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
277
- - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
731
+ - name: Generate SBOM
732
+ uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
278
733
  with:
279
734
  format: cyclonedx-json
280
735
  artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
@@ -282,13 +737,208 @@ jobs:
282
737
  # needs contents: write this workflow does not have, and fails the run that gates deploy.
283
738
  upload-release-assets: false
284
739
 
285
- # The merge queue requires this check. On a pull request with MERGE_QUEUE on, the heavy jobs are
286
- # skipped and the full CI runs on the merge group instead; skipped counts as passed.
740
+ # Builds the coverage, complexity and hygiene facets (uploaded from main only) and runs the
741
+ # blocking complexity and duplication gates. The coverage floors stay with the api and web jobs.
742
+ # Skips with them on a pull request that touches no app path. `sast-scan` is a need only for its
743
+ # Semgrep JSON (the hygiene facet's swallowed-exception counts); it finishes long before api.
744
+ # When api or web died before uploading what this job reads, the steps that read it skip (see the
745
+ # `inputs` step) rather than failing a second time with "Artifact not found" or ENOENT and burying
746
+ # the real failure. A missed coverage floor still uploads, so the facets are recorded then.
747
+ engineering-excellence:
748
+ name: Engineering Excellence (coverage, complexity)
749
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
750
+ timeout-minutes: 15
751
+ needs: [depth, api, web, sast-scan]
752
+ if: ${{ (!cancelled() && needs.api.result != 'skipped' && needs.web.result != 'skipped') && needs.depth.outputs.full == 'true' }}
753
+ steps:
754
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
755
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
756
+ with:
757
+ node-version: ${{ env.NODE_VERSION }}
758
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
759
+ continue-on-error: true
760
+ with: { name: api-coverage-merged, path: ee/api }
761
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
762
+ continue-on-error: true
763
+ with: { name: web-coverage, path: ee/web }
764
+ # upload-artifact keeps paths relative to the common ancestor of its inputs: find, don't assume.
765
+ - name: Check the coverage and complexity inputs
766
+ id: inputs
767
+ run: |
768
+ set -euo pipefail
769
+ find_one() { find "$1" -name "$2" 2>/dev/null | head -n 1 || true; }
770
+ API=ee/api/Cobertura.xml
771
+ WEB_COBERTURA=$(find_one ee/web cobertura-coverage.xml)
772
+ WEB_FINAL=$(find_one ee/web coverage-final.json)
773
+ ESLINT=$(find_one ee/web eslint-complexity.json)
774
+ missing=""
775
+ [ -f "$API" ] || missing="$missing Cobertura.xml (artifact api-coverage-merged, from the api job);"
776
+ [ -n "$WEB_COBERTURA" ] || missing="$missing cobertura-coverage.xml (artifact web-coverage, from the web job);"
777
+ [ -n "$WEB_FINAL" ] || missing="$missing coverage-final.json (artifact web-coverage, from the web job);"
778
+ [ -n "$ESLINT" ] || missing="$missing eslint-complexity.json (artifact web-coverage, from the web job);"
779
+ if [ -n "$missing" ]; then
780
+ msg="Coverage and complexity facets and the complexity gate skipped. Missing:$missing the upstream job that should have produced it is the failure to fix."
781
+ echo "::notice::$msg"
782
+ echo "$msg" >> "$GITHUB_STEP_SUMMARY"
783
+ echo "ready=false" >> "$GITHUB_OUTPUT"
784
+ exit 0
785
+ fi
786
+ {
787
+ echo "ready=true"
788
+ echo "api=$API"
789
+ echo "web_cobertura=$WEB_COBERTURA"
790
+ echo "web_final=$WEB_FINAL"
791
+ echo "eslint=$ESLINT"
792
+ } >> "$GITHUB_OUTPUT"
793
+ # A pattern download matches nothing (and does not fail) when the scan left no JSON.
794
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
795
+ with: { pattern: semgrep-json, path: ee/semgrep }
796
+ - name: Unit tests (facet builder, complexity and duplication gates, quality config)
797
+ run: |
798
+ # shellcheck disable=SC2086 # EXTRA_NODE_TESTS is a list of files.
799
+ node --test scripts/lib/*.test.mjs $EXTRA_NODE_TESTS
800
+ # Root devDependencies only (jscpd, pinned in package.json and the lockfile), not the web
801
+ # workspace: a few seconds.
802
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
803
+ with:
804
+ run_install: false
805
+ - name: Install jscpd
806
+ run: pnpm install --frozen-lockfile --filter "$ROOT_PACKAGE" --ignore-scripts
807
+ - name: Duplication report (jscpd)
808
+ run: pnpm exec jscpd -c .jscpd.json
809
+ - name: Build coverage and complexity facets
810
+ if: steps.inputs.outputs.ready == 'true'
811
+ env:
812
+ API: ${{ steps.inputs.outputs.api }}
813
+ WEB_COBERTURA: ${{ steps.inputs.outputs.web_cobertura }}
814
+ WEB_FINAL: ${{ steps.inputs.outputs.web_final }}
815
+ ESLINT: ${{ steps.inputs.outputs.eslint }}
816
+ run: |
817
+ set -euo pipefail
818
+ node scripts/engineering-excellence.mjs coverage --out ee/coverage.json \
819
+ --api "$API" --web "$WEB_COBERTURA" | tee -a "$GITHUB_STEP_SUMMARY"
820
+ node scripts/engineering-excellence.mjs complexity --out ee/complexity.json \
821
+ --api "$API" --web-final "$WEB_FINAL" --eslint "$ESLINT" \
822
+ --baseline scripts/complexity-baseline.json | tee -a "$GITHUB_STEP_SUMMARY"
823
+ # Reads only this job's jscpd report and the optional Semgrep JSON, not the api/web artifacts.
824
+ - name: Build hygiene facet
825
+ run: |
826
+ set -euo pipefail
827
+ SEMGREP=$(find ee/semgrep -name semgrep.json 2>/dev/null | head -n 1 || true)
828
+ node scripts/engineering-excellence.mjs hygiene --out ee/hygiene.json \
829
+ --jscpd jscpd-report/jscpd-report.json --semgrep "${SEMGREP:-ee/semgrep/missing.json}" \
830
+ --baseline scripts/duplication-baseline.json | tee -a "$GITHUB_STEP_SUMMARY"
831
+ # BLOCKING: every method must satisfy CC <= 15 and CRAP <= 30 unless it is recorded backlog in
832
+ # scripts/complexity-baseline.json, which may not get worse. Runs after the facets are built so
833
+ # a red gate still records them.
834
+ - name: Complexity gate (CC ≤ 15, CRAP ≤ 30)
835
+ if: ${{ !cancelled() && steps.inputs.outputs.ready == 'true' }}
836
+ env:
837
+ API: ${{ steps.inputs.outputs.api }}
838
+ WEB_FINAL: ${{ steps.inputs.outputs.web_final }}
839
+ ESLINT: ${{ steps.inputs.outputs.eslint }}
840
+ run: |
841
+ set -euo pipefail
842
+ node scripts/check-complexity.mjs --api "$API" \
843
+ --web-final "$WEB_FINAL" --eslint "$ESLINT" | tee -a "$GITHUB_STEP_SUMMARY"
844
+ # BLOCKING (practice 11): no file may hold more duplicated lines (jscpd clones of >= 10 lines
845
+ # and 70 tokens) than scripts/duplication-baseline.json records, so a copy-pasted block fails.
846
+ # Runs even when the complexity gate failed, so one red build reports both.
847
+ - name: Duplication gate (no new clones)
848
+ if: ${{ !cancelled() }}
849
+ run: |
850
+ set -euo pipefail
851
+ node scripts/check-duplication.mjs --report jscpd-report/jscpd-report.json | tee -a "$GITHUB_STEP_SUMMARY"
852
+ - name: Upload coverage facet
853
+ if: ${{ !cancelled() && steps.inputs.outputs.ready == 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
854
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
855
+ with:
856
+ name: engineering-excellence-coverage
857
+ path: ee/coverage.json
858
+ if-no-files-found: error
859
+ retention-days: 90
860
+ - name: Upload complexity facet
861
+ if: ${{ !cancelled() && steps.inputs.outputs.ready == 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
862
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
863
+ with:
864
+ name: engineering-excellence-complexity
865
+ path: ee/complexity.json
866
+ if-no-files-found: error
867
+ retention-days: 90
868
+ - name: Upload hygiene facet
869
+ if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
870
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
871
+ with:
872
+ name: engineering-excellence-hygiene
873
+ path: ee/hygiene.json
874
+ if-no-files-found: error
875
+ retention-days: 90
876
+
877
+ # Summarises the scanners above into the `engineering-excellence-security` and `-sbom` facets.
878
+ # The verdicts stay with the blocking jobs; this records what they saw, pass or fail.
879
+ engineering-excellence-security:
880
+ name: Engineering Excellence (security, SBOM)
881
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
882
+ timeout-minutes: 10
883
+ needs: [secret-scan, deps-and-iac-scan, deps-and-iac-report, sast-scan, sbom]
884
+ if: ${{ !cancelled() }}
885
+ steps:
886
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
887
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
888
+ with:
889
+ node-version: ${{ env.NODE_VERSION }}
890
+ # pattern: matches nothing without failing, so a scanner that died before writing its report
891
+ # leaves its block null instead of breaking the facet.
892
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
893
+ with: { pattern: trivy-json, path: ee/trivy }
894
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
895
+ with: { pattern: semgrep-json, path: ee/semgrep }
896
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
897
+ with: { pattern: "${{ env.SBOM_ARTIFACT_NAME }}", path: ee/sbom }
898
+ - name: Build facets
899
+ env:
900
+ SECRET_SCAN: ${{ needs.secret-scan.result }}
901
+ DEPS_AND_IAC: ${{ needs.deps-and-iac-scan.result }}
902
+ SAST: ${{ needs.sast-scan.result }}
903
+ SBOM: ${{ needs.sbom.result }}
904
+ run: |
905
+ set -euo pipefail
906
+ # A pattern download nests each artifact under its name; find, don't assume.
907
+ TRIVY=$(find ee/trivy -name trivy.json 2>/dev/null | head -n 1 || true)
908
+ SEMGREP=$(find ee/semgrep -name semgrep.json 2>/dev/null | head -n 1 || true)
909
+ node scripts/engineering-excellence.mjs security --out ee/security.json \
910
+ --trivy "${TRIVY:-ee/trivy/missing.json}" --semgrep "${SEMGREP:-ee/semgrep/missing.json}" \
911
+ --job "secretScan=$SECRET_SCAN" --job "depsAndIac=$DEPS_AND_IAC" --job "sast=$SAST" \
912
+ | tee -a "$GITHUB_STEP_SUMMARY"
913
+ BOM=$(find ee/sbom -type f -name '*.json' 2>/dev/null | head -n 1 || true)
914
+ if [ -n "$BOM" ]; then
915
+ node scripts/engineering-excellence.mjs sbom --out ee/sbom.json --cyclonedx "$BOM" | tee -a "$GITHUB_STEP_SUMMARY"
916
+ else
917
+ echo "::warning::no SBOM artifact (sbom job: $SBOM) — sbom facet not built"
918
+ fi
919
+ - name: Upload security facet
920
+ if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
921
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
922
+ with:
923
+ name: engineering-excellence-security
924
+ path: ee/security.json
925
+ if-no-files-found: error
926
+ retention-days: 90
927
+ - name: Upload SBOM facet
928
+ if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
929
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
930
+ with:
931
+ name: engineering-excellence-sbom
932
+ path: ee/sbom.json
933
+ if-no-files-found: warn
934
+ retention-days: 90
935
+
936
+ # The merge train reads this job by its name to tell a full run from a light one: do not rename it.
287
937
  full-ci:
288
938
  name: Full CI
289
- needs: [api, web]
290
- if: always()
291
- runs-on: agents-arc
939
+ needs: [depth, api, web, e2e, mutation-api, mutation-web, engineering-excellence]
940
+ if: ${{ always() && needs.depth.outputs.full == 'true' }}
941
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
292
942
  timeout-minutes: 5
293
943
  steps:
294
944
  - name: Every heavy job passed
@@ -296,43 +946,52 @@ jobs:
296
946
  RESULTS: ${{ join(needs.*.result, ' ') }}
297
947
  run: |
298
948
  for result in $RESULTS; do
299
- case "$result" in failure | cancelled | timed_out) echo "A heavy job ended $result."; exit 1 ;; esac
949
+ case "$result" in
950
+ failure | cancelled | timed_out) echo "A heavy job ended $result."; exit 1 ;;
951
+ esac
300
952
  done
953
+ echo "Full CI passed."
301
954
 
302
- # Hands this run's verdict to the router's merge train, which decides whether this pull request
303
- # is the one in front and what it needs. A pull request opened or updated with a bot token gets
304
- # no workflow_run event for its CI (the run's actor is the bot), and the train brings people's
305
- # pull requests up to date with one too, so every pull request on this repository hands over
306
- # from here. workflow_dispatch is one of the two events GITHUB_TOKEN may raise, so no App token
307
- # is needed, and the router treats a dispatch as trusted. The hourly schedule is the fallback.
955
+ # Hands this run's verdict to the router's merge train. A pull request opened or updated with a
956
+ # bot token gets no workflow_run event for its CI (the run's actor is the bot), so a bot's pull
957
+ # request hands over from here; people's pull requests reach the train through workflow_run.
958
+ # GITHUB_TOKEN on purpose: workflow_dispatch is one of the two events it may raise, so no App
959
+ # token is needed, and this workflow is also a workflow_call target that declares no App secrets.
308
960
  #
309
961
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
310
962
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
311
963
  # whole run fails to start with zero jobs and no annotation when it does.
312
- dispatch-merge-train:
313
- name: Dispatch merge train
314
- needs: [api, mutation-api, web, mutation-web]
964
+ dispatch-merge-gate:
965
+ name: Dispatch merge gate
966
+ needs: [api, mutation-api, web, mutation-web, secret-scan, deps-and-iac-scan, sast-scan, sbom, engineering-excellence, platform-conformance, full-ci]
315
967
  if: >
316
968
  always() &&
969
+ vars.AGENTIC_WORKFLOWS_DISABLED == '' &&
317
970
  github.event_name == 'pull_request' &&
318
971
  github.event.pull_request.head.repo.full_name == github.repository &&
319
- github.event.pull_request.draft == false
320
- runs-on: agents-arc
972
+ github.event.pull_request.draft == false &&
973
+ (github.event.pull_request.user.login == 'app/github-actions' || endsWith(github.event.pull_request.user.login, '[bot]'))
974
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
321
975
  timeout-minutes: 5
322
976
  permissions:
323
977
  contents: read
324
978
  actions: write
325
979
  steps:
326
- - name: Hand this run's verdict to the merge train
980
+ - name: Hand this run's verdict to the merge train after CI settles
327
981
  env:
328
982
  GH_TOKEN: ${{ github.token }}
983
+ # The jobs the apps judged before this template; the mutation runs are only waited for.
984
+ RESULTS: >-
985
+ ${{ needs.api.result }} ${{ needs.web.result }} ${{ needs.secret-scan.result }}
986
+ ${{ needs.deps-and-iac-scan.result }} ${{ needs.sast-scan.result }} ${{ needs.sbom.result }}
987
+ ${{ needs.engineering-excellence.result }} ${{ needs.platform-conformance.result }}
988
+ PR_NUMBER: ${{ github.event.pull_request.number }}
329
989
  REPO: ${{ github.repository }}
330
990
  REF: ${{ github.event.repository.default_branch }}
331
- PR_NUMBER: ${{ github.event.pull_request.number }}
332
991
  RUN_ID: ${{ github.run_id }}
333
- RESULTS: ${{ join(needs.*.result, ' ') }}
334
992
  run: |
335
993
  set -euo pipefail
994
+ sleep 60
336
995
  conclusion=success
337
996
  for result in $RESULTS; do
338
997
  case "$result" in