@plainconceptsplatform/workflows 1.10.1 → 1.12.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 (34) 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/templates/ci/app-ci-dotnet-next.yml +778 -117
  10. package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +202 -0
  11. package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +453 -0
  12. package/loops/templates/quality-gates/.jscpd.json +36 -0
  13. package/loops/templates/quality-gates/.jscpd.json.md +7 -0
  14. package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
  15. package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
  16. package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
  17. package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
  18. package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
  19. package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
  20. package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
  21. package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
  22. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
  23. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
  24. package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
  25. package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
  26. package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
  27. package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
  28. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
  29. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
  30. package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
  31. package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
  32. package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
  33. package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
  34. 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 declares a platform contract.
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,284 @@ 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
+ # Paths a platform contract (.platform/contract.yml) marks as owned change only through a template
550
+ # sync. The guard comes with the platform-template template; a repository with no contract (nor
551
+ # .copier-answers.yml with .platform/manifest, read as one) has not adopted it and passes. The
552
+ # owned files are also compared with the template at the recorded version wherever git can read it.
553
+ platform-conformance:
554
+ name: Platform conformance
555
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
556
+ timeout-minutes: 5
557
+ steps:
558
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
559
+ with:
560
+ fetch-depth: 0
561
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
562
+ with:
563
+ node-version: ${{ env.NODE_VERSION }}
564
+ - name: Owned files change only through a template sync
565
+ env:
566
+ EVENT: ${{ github.event_name }}
567
+ BASE_REF: ${{ github.base_ref }}
568
+ run: |
569
+ set -euo pipefail
570
+ if [ ! -f .platform/contract.yml ] && { [ ! -f .copier-answers.yml ] || [ ! -f .platform/manifest ]; }; then
571
+ echo "No .platform/contract.yml: this repository declares no platform contract."
572
+ exit 0
573
+ fi
574
+ if [ ! -f scripts/check-platform-conformance.mjs ]; then
575
+ echo "::error::This repository has a platform contract but no scripts/check-platform-conformance.mjs. Install it with: npx @plainconceptsplatform/workflows add --template platform-template"
576
+ exit 1
577
+ fi
578
+ if [ "$EVENT" = pull_request ]; then
579
+ git fetch origin "$BASE_REF"
580
+ node scripts/check-platform-conformance.mjs --compare --base "origin/$BASE_REF" | tee -a "$GITHUB_STEP_SUMMARY"
581
+ else
582
+ node scripts/check-platform-conformance.mjs --compare | tee -a "$GITHUB_STEP_SUMMARY"
583
+ fi
584
+
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
220
595
  - name: Detect web changes
221
596
  id: scope
222
597
  run: |
@@ -227,6 +602,16 @@ jobs:
227
602
  echo "run=false" >> "$GITHUB_OUTPUT"
228
603
  echo "No apps/web changes in this PR; skipping web mutation."
229
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
230
615
  - name: Configure optional private registry authentication
231
616
  if: steps.scope.outputs.run == 'true'
232
617
  env:
@@ -239,21 +624,45 @@ jobs:
239
624
  if: steps.scope.outputs.run == 'true'
240
625
  run: |
241
626
  pnpm install --frozen-lockfile
242
- pnpm --filter "$WEB_PACKAGE" exec stryker run --since="origin/$GITHUB_BASE_REF"
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"
243
643
 
244
644
  secret-scan:
245
- name: Secret scan (TruffleHog)
246
- runs-on: agents-arc
645
+ name: Secret scan (TruffleHog, verified only)
646
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
247
647
  steps:
248
648
  - 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
649
+ - name: TruffleHog filesystem scan
650
+ env:
651
+ WORKSPACE: ${{ github.workspace }}
652
+ run: |
653
+ docker run --rm -v "$WORKSPACE:/repo" "$TRUFFLEHOG_IMAGE" \
654
+ filesystem /repo --only-verified --fail --no-update
250
655
 
656
+ # vuln: pnpm-lock.yaml and the .NET package props. misconfig: infra and the Dockerfile. secret:
657
+ # the whole tree. CRITICAL/HIGH only, unfixed advisories ignored so an unpatched upstream CVE
658
+ # cannot block every PR.
251
659
  deps-and-iac-scan:
252
- name: Dependencies and IaC (Trivy)
253
- runs-on: agents-arc
660
+ name: Dependencies & IaC (Trivy, blocking)
661
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
254
662
  steps:
255
663
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
256
- - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
664
+ - name: Trivy filesystem scan
665
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
257
666
  with:
258
667
  scan-type: fs
259
668
  scanners: vuln,misconfig,secret
@@ -262,19 +671,67 @@ jobs:
262
671
  exit-code: "1"
263
672
  format: table
264
673
 
674
+ # Machine-readable Trivy result for the security facet: every severity, unfixed included, never
675
+ # failing. A job of its own so nothing in this report-only run can leak into the gate above.
676
+ deps-and-iac-report:
677
+ name: Dependencies & IaC report (Trivy JSON, report-only)
678
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
679
+ steps:
680
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
681
+ - name: Trivy filesystem scan (JSON)
682
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
683
+ with:
684
+ scan-type: fs
685
+ scanners: vuln,misconfig,secret
686
+ severity: CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN
687
+ exit-code: "0"
688
+ format: json
689
+ output: trivy.json
690
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
691
+ with:
692
+ name: trivy-json
693
+ path: trivy.json
694
+ if-no-files-found: error
695
+ retention-days: 1
696
+
265
697
  sast-scan:
266
- name: SAST (Semgrep)
267
- runs-on: agents-arc
698
+ name: SAST (Semgrep OSS, blocking)
699
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
268
700
  steps:
269
701
  - 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
702
+ # --json-output writes the same findings as the blocking text output, for the security facet.
703
+ # `--config .semgrep/` adds the hygiene rules (empty catch blocks, practice 11) from the
704
+ # quality-gates template; their findings also feed the hygiene facet.
705
+ - name: Semgrep scan (blocking)
706
+ env:
707
+ WORKSPACE: ${{ github.workspace }}
708
+ run: |
709
+ set -euo pipefail
710
+ excludes=()
711
+ for path in $SEMGREP_EXCLUDES; do excludes+=(--exclude "$path"); done
712
+ docker run --rm -v "$WORKSPACE:/src" -w /src "$SEMGREP_IMAGE" \
713
+ semgrep scan --error --metrics=off --oss-only --disable-version-check \
714
+ --config p/default --config p/security-audit --config p/secrets \
715
+ --config p/csharp --config p/typescript --config p/react --config .semgrep/ \
716
+ --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag \
717
+ "${excludes[@]}" \
718
+ --json-output=semgrep.json
719
+ # Uploaded when the scan fails too: a failing run is exactly the one worth recording.
720
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
721
+ if: ${{ !cancelled() }}
722
+ with:
723
+ name: semgrep-json
724
+ path: semgrep.json
725
+ if-no-files-found: ignore
726
+ retention-days: 1
271
727
 
272
728
  sbom:
273
- name: SBOM (CycloneDX)
274
- runs-on: agents-arc
729
+ name: SBOM (Syft, CycloneDX)
730
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
275
731
  steps:
276
732
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
277
- - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
733
+ - name: Generate SBOM
734
+ uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
278
735
  with:
279
736
  format: cyclonedx-json
280
737
  artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
@@ -282,13 +739,208 @@ jobs:
282
739
  # needs contents: write this workflow does not have, and fails the run that gates deploy.
283
740
  upload-release-assets: false
284
741
 
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.
742
+ # Builds the coverage, complexity and hygiene facets (uploaded from main only) and runs the
743
+ # blocking complexity and duplication gates. The coverage floors stay with the api and web jobs.
744
+ # Skips with them on a pull request that touches no app path. `sast-scan` is a need only for its
745
+ # Semgrep JSON (the hygiene facet's swallowed-exception counts); it finishes long before api.
746
+ # When api or web died before uploading what this job reads, the steps that read it skip (see the
747
+ # `inputs` step) rather than failing a second time with "Artifact not found" or ENOENT and burying
748
+ # the real failure. A missed coverage floor still uploads, so the facets are recorded then.
749
+ engineering-excellence:
750
+ name: Engineering Excellence (coverage, complexity)
751
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
752
+ timeout-minutes: 15
753
+ needs: [depth, api, web, sast-scan]
754
+ if: ${{ (!cancelled() && needs.api.result != 'skipped' && needs.web.result != 'skipped') && needs.depth.outputs.full == 'true' }}
755
+ steps:
756
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
757
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
758
+ with:
759
+ node-version: ${{ env.NODE_VERSION }}
760
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
761
+ continue-on-error: true
762
+ with: { name: api-coverage-merged, path: ee/api }
763
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
764
+ continue-on-error: true
765
+ with: { name: web-coverage, path: ee/web }
766
+ # upload-artifact keeps paths relative to the common ancestor of its inputs: find, don't assume.
767
+ - name: Check the coverage and complexity inputs
768
+ id: inputs
769
+ run: |
770
+ set -euo pipefail
771
+ find_one() { find "$1" -name "$2" 2>/dev/null | head -n 1 || true; }
772
+ API=ee/api/Cobertura.xml
773
+ WEB_COBERTURA=$(find_one ee/web cobertura-coverage.xml)
774
+ WEB_FINAL=$(find_one ee/web coverage-final.json)
775
+ ESLINT=$(find_one ee/web eslint-complexity.json)
776
+ missing=""
777
+ [ -f "$API" ] || missing="$missing Cobertura.xml (artifact api-coverage-merged, from the api job);"
778
+ [ -n "$WEB_COBERTURA" ] || missing="$missing cobertura-coverage.xml (artifact web-coverage, from the web job);"
779
+ [ -n "$WEB_FINAL" ] || missing="$missing coverage-final.json (artifact web-coverage, from the web job);"
780
+ [ -n "$ESLINT" ] || missing="$missing eslint-complexity.json (artifact web-coverage, from the web job);"
781
+ if [ -n "$missing" ]; then
782
+ 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."
783
+ echo "::notice::$msg"
784
+ echo "$msg" >> "$GITHUB_STEP_SUMMARY"
785
+ echo "ready=false" >> "$GITHUB_OUTPUT"
786
+ exit 0
787
+ fi
788
+ {
789
+ echo "ready=true"
790
+ echo "api=$API"
791
+ echo "web_cobertura=$WEB_COBERTURA"
792
+ echo "web_final=$WEB_FINAL"
793
+ echo "eslint=$ESLINT"
794
+ } >> "$GITHUB_OUTPUT"
795
+ # A pattern download matches nothing (and does not fail) when the scan left no JSON.
796
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
797
+ with: { pattern: semgrep-json, path: ee/semgrep }
798
+ - name: Unit tests (facet builder, complexity and duplication gates, quality config)
799
+ run: |
800
+ # shellcheck disable=SC2086 # EXTRA_NODE_TESTS is a list of files.
801
+ node --test scripts/lib/*.test.mjs $EXTRA_NODE_TESTS
802
+ # Root devDependencies only (jscpd, pinned in package.json and the lockfile), not the web
803
+ # workspace: a few seconds.
804
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
805
+ with:
806
+ run_install: false
807
+ - name: Install jscpd
808
+ run: pnpm install --frozen-lockfile --filter "$ROOT_PACKAGE" --ignore-scripts
809
+ - name: Duplication report (jscpd)
810
+ run: pnpm exec jscpd -c .jscpd.json
811
+ - name: Build coverage and complexity facets
812
+ if: steps.inputs.outputs.ready == 'true'
813
+ env:
814
+ API: ${{ steps.inputs.outputs.api }}
815
+ WEB_COBERTURA: ${{ steps.inputs.outputs.web_cobertura }}
816
+ WEB_FINAL: ${{ steps.inputs.outputs.web_final }}
817
+ ESLINT: ${{ steps.inputs.outputs.eslint }}
818
+ run: |
819
+ set -euo pipefail
820
+ node scripts/engineering-excellence.mjs coverage --out ee/coverage.json \
821
+ --api "$API" --web "$WEB_COBERTURA" | tee -a "$GITHUB_STEP_SUMMARY"
822
+ node scripts/engineering-excellence.mjs complexity --out ee/complexity.json \
823
+ --api "$API" --web-final "$WEB_FINAL" --eslint "$ESLINT" \
824
+ --baseline scripts/complexity-baseline.json | tee -a "$GITHUB_STEP_SUMMARY"
825
+ # Reads only this job's jscpd report and the optional Semgrep JSON, not the api/web artifacts.
826
+ - name: Build hygiene facet
827
+ run: |
828
+ set -euo pipefail
829
+ SEMGREP=$(find ee/semgrep -name semgrep.json 2>/dev/null | head -n 1 || true)
830
+ node scripts/engineering-excellence.mjs hygiene --out ee/hygiene.json \
831
+ --jscpd jscpd-report/jscpd-report.json --semgrep "${SEMGREP:-ee/semgrep/missing.json}" \
832
+ --baseline scripts/duplication-baseline.json | tee -a "$GITHUB_STEP_SUMMARY"
833
+ # BLOCKING: every method must satisfy CC <= 15 and CRAP <= 30 unless it is recorded backlog in
834
+ # scripts/complexity-baseline.json, which may not get worse. Runs after the facets are built so
835
+ # a red gate still records them.
836
+ - name: Complexity gate (CC ≤ 15, CRAP ≤ 30)
837
+ if: ${{ !cancelled() && steps.inputs.outputs.ready == 'true' }}
838
+ env:
839
+ API: ${{ steps.inputs.outputs.api }}
840
+ WEB_FINAL: ${{ steps.inputs.outputs.web_final }}
841
+ ESLINT: ${{ steps.inputs.outputs.eslint }}
842
+ run: |
843
+ set -euo pipefail
844
+ node scripts/check-complexity.mjs --api "$API" \
845
+ --web-final "$WEB_FINAL" --eslint "$ESLINT" | tee -a "$GITHUB_STEP_SUMMARY"
846
+ # BLOCKING (practice 11): no file may hold more duplicated lines (jscpd clones of >= 10 lines
847
+ # and 70 tokens) than scripts/duplication-baseline.json records, so a copy-pasted block fails.
848
+ # Runs even when the complexity gate failed, so one red build reports both.
849
+ - name: Duplication gate (no new clones)
850
+ if: ${{ !cancelled() }}
851
+ run: |
852
+ set -euo pipefail
853
+ node scripts/check-duplication.mjs --report jscpd-report/jscpd-report.json | tee -a "$GITHUB_STEP_SUMMARY"
854
+ - name: Upload coverage facet
855
+ if: ${{ !cancelled() && steps.inputs.outputs.ready == 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
856
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
857
+ with:
858
+ name: engineering-excellence-coverage
859
+ path: ee/coverage.json
860
+ if-no-files-found: error
861
+ retention-days: 90
862
+ - name: Upload complexity facet
863
+ if: ${{ !cancelled() && steps.inputs.outputs.ready == 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
864
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
865
+ with:
866
+ name: engineering-excellence-complexity
867
+ path: ee/complexity.json
868
+ if-no-files-found: error
869
+ retention-days: 90
870
+ - name: Upload hygiene facet
871
+ if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
872
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
873
+ with:
874
+ name: engineering-excellence-hygiene
875
+ path: ee/hygiene.json
876
+ if-no-files-found: error
877
+ retention-days: 90
878
+
879
+ # Summarises the scanners above into the `engineering-excellence-security` and `-sbom` facets.
880
+ # The verdicts stay with the blocking jobs; this records what they saw, pass or fail.
881
+ engineering-excellence-security:
882
+ name: Engineering Excellence (security, SBOM)
883
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
884
+ timeout-minutes: 10
885
+ needs: [secret-scan, deps-and-iac-scan, deps-and-iac-report, sast-scan, sbom]
886
+ if: ${{ !cancelled() }}
887
+ steps:
888
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
889
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
890
+ with:
891
+ node-version: ${{ env.NODE_VERSION }}
892
+ # pattern: matches nothing without failing, so a scanner that died before writing its report
893
+ # leaves its block null instead of breaking the facet.
894
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
895
+ with: { pattern: trivy-json, path: ee/trivy }
896
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
897
+ with: { pattern: semgrep-json, path: ee/semgrep }
898
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
899
+ with: { pattern: "${{ env.SBOM_ARTIFACT_NAME }}", path: ee/sbom }
900
+ - name: Build facets
901
+ env:
902
+ SECRET_SCAN: ${{ needs.secret-scan.result }}
903
+ DEPS_AND_IAC: ${{ needs.deps-and-iac-scan.result }}
904
+ SAST: ${{ needs.sast-scan.result }}
905
+ SBOM: ${{ needs.sbom.result }}
906
+ run: |
907
+ set -euo pipefail
908
+ # A pattern download nests each artifact under its name; find, don't assume.
909
+ TRIVY=$(find ee/trivy -name trivy.json 2>/dev/null | head -n 1 || true)
910
+ SEMGREP=$(find ee/semgrep -name semgrep.json 2>/dev/null | head -n 1 || true)
911
+ node scripts/engineering-excellence.mjs security --out ee/security.json \
912
+ --trivy "${TRIVY:-ee/trivy/missing.json}" --semgrep "${SEMGREP:-ee/semgrep/missing.json}" \
913
+ --job "secretScan=$SECRET_SCAN" --job "depsAndIac=$DEPS_AND_IAC" --job "sast=$SAST" \
914
+ | tee -a "$GITHUB_STEP_SUMMARY"
915
+ BOM=$(find ee/sbom -type f -name '*.json' 2>/dev/null | head -n 1 || true)
916
+ if [ -n "$BOM" ]; then
917
+ node scripts/engineering-excellence.mjs sbom --out ee/sbom.json --cyclonedx "$BOM" | tee -a "$GITHUB_STEP_SUMMARY"
918
+ else
919
+ echo "::warning::no SBOM artifact (sbom job: $SBOM) — sbom facet not built"
920
+ fi
921
+ - name: Upload security facet
922
+ if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
923
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
924
+ with:
925
+ name: engineering-excellence-security
926
+ path: ee/security.json
927
+ if-no-files-found: error
928
+ retention-days: 90
929
+ - name: Upload SBOM facet
930
+ if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
931
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
932
+ with:
933
+ name: engineering-excellence-sbom
934
+ path: ee/sbom.json
935
+ if-no-files-found: warn
936
+ retention-days: 90
937
+
938
+ # The merge train reads this job by its name to tell a full run from a light one: do not rename it.
287
939
  full-ci:
288
940
  name: Full CI
289
- needs: [api, web]
290
- if: always()
291
- runs-on: agents-arc
941
+ needs: [depth, api, web, e2e, mutation-api, mutation-web, engineering-excellence]
942
+ if: ${{ always() && needs.depth.outputs.full == 'true' }}
943
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
292
944
  timeout-minutes: 5
293
945
  steps:
294
946
  - name: Every heavy job passed
@@ -296,43 +948,52 @@ jobs:
296
948
  RESULTS: ${{ join(needs.*.result, ' ') }}
297
949
  run: |
298
950
  for result in $RESULTS; do
299
- case "$result" in failure | cancelled | timed_out) echo "A heavy job ended $result."; exit 1 ;; esac
951
+ case "$result" in
952
+ failure | cancelled | timed_out) echo "A heavy job ended $result."; exit 1 ;;
953
+ esac
300
954
  done
955
+ echo "Full CI passed."
301
956
 
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.
957
+ # Hands this run's verdict to the router's merge train. A pull request opened or updated with a
958
+ # bot token gets no workflow_run event for its CI (the run's actor is the bot), so a bot's pull
959
+ # request hands over from here; people's pull requests reach the train through workflow_run.
960
+ # GITHUB_TOKEN on purpose: workflow_dispatch is one of the two events it may raise, so no App
961
+ # token is needed, and this workflow is also a workflow_call target that declares no App secrets.
308
962
  #
309
963
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
310
964
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
311
965
  # 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]
966
+ dispatch-merge-gate:
967
+ name: Dispatch merge gate
968
+ needs: [api, mutation-api, web, mutation-web, secret-scan, deps-and-iac-scan, sast-scan, sbom, engineering-excellence, platform-conformance, full-ci]
315
969
  if: >
316
970
  always() &&
971
+ vars.AGENTIC_WORKFLOWS_DISABLED == '' &&
317
972
  github.event_name == 'pull_request' &&
318
973
  github.event.pull_request.head.repo.full_name == github.repository &&
319
- github.event.pull_request.draft == false
320
- runs-on: agents-arc
974
+ github.event.pull_request.draft == false &&
975
+ (github.event.pull_request.user.login == 'app/github-actions' || endsWith(github.event.pull_request.user.login, '[bot]'))
976
+ runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
321
977
  timeout-minutes: 5
322
978
  permissions:
323
979
  contents: read
324
980
  actions: write
325
981
  steps:
326
- - name: Hand this run's verdict to the merge train
982
+ - name: Hand this run's verdict to the merge train after CI settles
327
983
  env:
328
984
  GH_TOKEN: ${{ github.token }}
985
+ # The jobs the apps judged before this template; the mutation runs are only waited for.
986
+ RESULTS: >-
987
+ ${{ needs.api.result }} ${{ needs.web.result }} ${{ needs.secret-scan.result }}
988
+ ${{ needs.deps-and-iac-scan.result }} ${{ needs.sast-scan.result }} ${{ needs.sbom.result }}
989
+ ${{ needs.engineering-excellence.result }} ${{ needs.platform-conformance.result }}
990
+ PR_NUMBER: ${{ github.event.pull_request.number }}
329
991
  REPO: ${{ github.repository }}
330
992
  REF: ${{ github.event.repository.default_branch }}
331
- PR_NUMBER: ${{ github.event.pull_request.number }}
332
993
  RUN_ID: ${{ github.run_id }}
333
- RESULTS: ${{ join(needs.*.result, ' ') }}
334
994
  run: |
335
995
  set -euo pipefail
996
+ sleep 60
336
997
  conclusion=success
337
998
  for result in $RESULTS; do
338
999
  case "$result" in