@plainconceptsplatform/workflows 0.28.12 → 0.28.14

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.
@@ -1,330 +1,330 @@
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
- name: "App: CI"
3
-
4
- on:
5
- pull_request:
6
- schedule:
7
- - cron: "0 6 * * 1"
8
- workflow_dispatch:
9
- workflow_call:
10
- secrets:
11
- NPM_REGISTRY_TOKEN:
12
- required: false
13
-
14
- env:
15
- DOTNET_VERSION: "10.0.x"
16
- NODE_VERSION: "22"
17
- PNPM_VERSION: "10"
18
- API_WORKING_DIRECTORY: apps/api
19
- DOTNET_SOLUTION: App.slnx
20
- UNIT_TEST_PROJECT: tests/App.UnitTests/App.UnitTests.csproj
21
- PARITY_TEST_PROJECT: tests/App.ParityTests/App.ParityTests.csproj
22
- INTEGRATION_TEST_PROJECT: tests/App.IntegrationTests/App.IntegrationTests.csproj
23
- # Per-PR mutation gate. Stryker mutates only files changed since the PR base (--since),
24
- # so a green unit suite that never actually pins behaviour fails here instead of shipping.
25
- # dotnet-stryker is pinned in the api tools manifest (.config/dotnet-tools.json / dotnet-tools.json).
26
- MUTATION_TARGET_PROJECT: src/App.Domain/App.Domain.csproj
27
- MUTATION_TEST_PROJECT: tests/App.UnitTests/App.UnitTests.csproj
28
- MUTATION_THRESHOLD: "60"
29
- COVERAGE_SETTINGS: coverage.runsettings
30
- COVERAGE_CHECK_COMMAND: node ../../scripts/check-coverage.mjs TestResults/merged/Cobertura.xml
31
- MIGRATION_PROJECT: src/App.Infrastructure/App.Infrastructure.csproj
32
- MIGRATION_STARTUP_PROJECT: src/App.Api/App.Api.csproj
33
- SEED_PROJECT: src/App.Seed
34
- PRIMARY_DATABASE: app
35
- DATA_LAYER_DATABASE: numa_dl
36
- # Derived per run rather than written down. The container is ephemeral, bound to a random
37
- # host port and reachable only from this job, so the value protects nothing, but a literal
38
- # password in a config file is a policy finding wherever it sits, and this file is public.
39
- # It has to be a workflow-level expression: service containers receive their environment
40
- # before any step runs, so a value generated in a step would arrive too late.
41
- #
42
- # The same expression is repeated inside the service's `options` above rather than read
43
- # from here, because the `env` context is not available in `jobs.<id>.services` — only
44
- # inside that service's own `env:`. Written as ${{ env.SQL_SA_PASSWORD }} there, the health
45
- # check probes with an empty password and the service never reports healthy.
46
- SQL_SA_PASSWORD: "Aa1!${{ github.run_id }}"
47
- WEB_PACKAGE: "@app/web"
48
- NPM_REGISTRY_URL: "//npm.pkg.github.com/"
49
- TRUFFLEHOG_IMAGE: "trufflesecurity/trufflehog:latest"
50
- SEMGREP_IMAGE: "semgrep/semgrep:1.168.0"
51
- SBOM_ARTIFACT_NAME: app-sbom.cyclonedx.json
52
- MIGRATION_CONTEXT: AppDbContext
53
-
54
- permissions:
55
- contents: read
56
-
57
- concurrency:
58
- group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
59
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
60
-
61
- jobs:
62
- api:
63
- name: API (.NET)
64
- if: github.event_name != 'schedule'
65
- runs-on: RunnerLandingZone
66
- timeout-minutes: 30
67
- services:
68
- sqlserver:
69
- image: mcr.microsoft.com/mssql/server:2022-latest
70
- env:
71
- ACCEPT_EULA: "Y"
72
- MSSQL_SA_PASSWORD: ${{ env.SQL_SA_PASSWORD }}
73
- ports:
74
- # Ephemeral host port. Four runners share this machine, so a fixed 1433 means the
75
- # second concurrent job fails at Initialize containers with "Docker start fail with
76
- # exit code 1". Steps read the assigned port from the job.services context.
77
- - 1433
78
- options: >-
79
- --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Aa1!${{ github.run_id }}' -Q 'SELECT 1' -C || exit 1"
80
- --health-interval 10s --health-timeout 5s --health-retries 12 --health-start-period 30s
81
- defaults:
82
- run:
83
- working-directory: ${{ env.API_WORKING_DIRECTORY }}
84
- steps:
85
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
86
- - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
87
- with:
88
- dotnet-version: ${{ env.DOTNET_VERSION }}
89
- - name: Restore tools, solution, and build
90
- run: |
91
- dotnet tool restore
92
- dotnet restore "$DOTNET_SOLUTION"
93
- dotnet build "$DOTNET_SOLUTION" --no-restore -c Release
94
- - name: Unit tests with coverage
95
- run: dotnet test "$UNIT_TEST_PROJECT" --no-build -c Release --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
96
- - name: Parity tests
97
- run: dotnet test "$PARITY_TEST_PROJECT" --no-build -c Release
98
- - name: Create test databases
99
- run: |
100
- for database in "$PRIMARY_DATABASE" "$DATA_LAYER_DATABASE"; do
101
- 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];"
102
- done
103
- - name: Apply migrations and seed test data
104
- env:
105
- ConnectionStrings__App: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.PRIMARY_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
106
- DataLayer__ConnectionString: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.DATA_LAYER_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
107
- DATALAYER_SEED: "true"
108
- run: |
109
- dotnet ef database update -p "$MIGRATION_PROJECT" -s "$MIGRATION_STARTUP_PROJECT" --context "$MIGRATION_CONTEXT"
110
- dotnet run --project "$SEED_PROJECT" --no-build -c Release
111
- - name: Integration tests with coverage
112
- env:
113
- ConnectionStrings__App: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.PRIMARY_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
114
- DataLayer__ConnectionString: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.DATA_LAYER_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
115
- run: dotnet test "$INTEGRATION_TEST_PROJECT" --no-build -c Release --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
116
- - name: Enforce merged coverage floor
117
- run: |
118
- dotnet tool install --global dotnet-reportgenerator-globaltool
119
- export PATH="$PATH:$HOME/.dotnet/tools" # ephemeral ARC runners do not have dotnet tools on PATH
120
- reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:TestResults/merged -reporttypes:Cobertura
121
- bash -c "$COVERAGE_CHECK_COMMAND"
122
-
123
- mutation-api:
124
- name: Mutation (.NET, diff)
125
- if: github.event_name == 'pull_request'
126
- runs-on: RunnerLandingZone
127
- timeout-minutes: 25
128
- defaults:
129
- run:
130
- working-directory: ${{ env.API_WORKING_DIRECTORY }}
131
- steps:
132
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
133
- with:
134
- # Stryker --since diffs against the PR base; it needs history, not a shallow tip.
135
- fetch-depth: 0
136
- - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
137
- with:
138
- dotnet-version: ${{ env.DOTNET_VERSION }}
139
- - name: Detect backend changes
140
- id: scope
141
- working-directory: ${{ github.workspace }}
142
- run: |
143
- git fetch origin "$GITHUB_BASE_REF" --depth=1
144
- if git diff --name-only "origin/$GITHUB_BASE_REF" HEAD | grep -qE "^${API_WORKING_DIRECTORY}/"; then
145
- echo "run=true" >> "$GITHUB_OUTPUT"
146
- else
147
- echo "run=false" >> "$GITHUB_OUTPUT"
148
- echo "No ${API_WORKING_DIRECTORY} changes in this PR; skipping .NET mutation."
149
- fi
150
- - name: Restore tools and build
151
- if: steps.scope.outputs.run == 'true'
152
- run: |
153
- dotnet tool restore
154
- dotnet build "$DOTNET_SOLUTION" -c Release
155
- - name: Stryker.NET (changed files only)
156
- if: steps.scope.outputs.run == 'true'
157
- run: |
158
- export PATH="$PATH:$HOME/.dotnet/tools" # ephemeral ARC runners do not have dotnet tools on PATH
159
- dotnet stryker \
160
- --project "$MUTATION_TARGET_PROJECT" \
161
- --test-projects "$MUTATION_TEST_PROJECT" \
162
- --since:"origin/$GITHUB_BASE_REF" \
163
- --concurrency 2 \
164
- --threshold-high "$MUTATION_THRESHOLD" \
165
- --threshold-low "$MUTATION_THRESHOLD" \
166
- --break-at "$MUTATION_THRESHOLD" \
167
- --reporter progress --reporter json
168
-
169
- web:
170
- name: Web (Next.js)
171
- if: github.event_name != 'schedule'
172
- runs-on: RunnerLandingZone
173
- timeout-minutes: 15
174
- steps:
175
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
176
- - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
177
- with:
178
- version: ${{ env.PNPM_VERSION }}
179
- run_install: false
180
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
181
- with:
182
- node-version: ${{ env.NODE_VERSION }}
183
- cache: pnpm
184
- cache-dependency-path: pnpm-lock.yaml
185
- - name: Configure optional private registry authentication
186
- env:
187
- NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
188
- run: |
189
- if [ -n "$NPM_REGISTRY_TOKEN" ]; then
190
- echo "${NPM_REGISTRY_URL}:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc
191
- fi
192
- - run: pnpm install --frozen-lockfile
193
- - name: Lint, test, verify boundaries, and build
194
- run: |
195
- pnpm --filter "$WEB_PACKAGE" run lint
196
- pnpm --filter "$WEB_PACKAGE" run test
197
- pnpm --filter "$WEB_PACKAGE" run lint:fsd
198
- pnpm --filter "$WEB_PACKAGE" run build
199
-
200
- mutation-web:
201
- name: Mutation (web, diff)
202
- if: github.event_name == 'pull_request'
203
- runs-on: RunnerLandingZone
204
- timeout-minutes: 25
205
- steps:
206
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
207
- with:
208
- fetch-depth: 0
209
- - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
210
- with:
211
- version: ${{ env.PNPM_VERSION }}
212
- run_install: false
213
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
214
- with:
215
- node-version: ${{ env.NODE_VERSION }}
216
- cache: pnpm
217
- cache-dependency-path: pnpm-lock.yaml
218
- - name: Detect web changes
219
- id: scope
220
- run: |
221
- git fetch origin "$GITHUB_BASE_REF" --depth=1
222
- if git diff --name-only "origin/$GITHUB_BASE_REF" HEAD | grep -qE "^apps/web/"; then
223
- echo "run=true" >> "$GITHUB_OUTPUT"
224
- else
225
- echo "run=false" >> "$GITHUB_OUTPUT"
226
- echo "No apps/web changes in this PR; skipping web mutation."
227
- fi
228
- - name: Configure optional private registry authentication
229
- if: steps.scope.outputs.run == 'true'
230
- env:
231
- NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
232
- run: |
233
- if [ -n "$NPM_REGISTRY_TOKEN" ]; then
234
- echo "${NPM_REGISTRY_URL}:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc
235
- fi
236
- - name: Install and run StrykerJS (changed files only)
237
- if: steps.scope.outputs.run == 'true'
238
- run: |
239
- pnpm install --frozen-lockfile
240
- pnpm --filter "$WEB_PACKAGE" exec stryker run --since="origin/$GITHUB_BASE_REF"
241
-
242
- secret-scan:
243
- name: Secret scan (TruffleHog)
244
- runs-on: RunnerLandingZone
245
- steps:
246
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
247
- - run: docker run --rm -v "${{ github.workspace }}:/repo" "$TRUFFLEHOG_IMAGE" filesystem /repo --only-verified --fail --no-update
248
-
249
- deps-and-iac-scan:
250
- name: Dependencies and IaC (Trivy)
251
- runs-on: RunnerLandingZone
252
- steps:
253
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
254
- - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
255
- with:
256
- scan-type: fs
257
- scanners: vuln,misconfig,secret
258
- severity: CRITICAL,HIGH
259
- ignore-unfixed: true
260
- exit-code: "1"
261
- format: table
262
-
263
- sast-scan:
264
- name: SAST (Semgrep)
265
- runs-on: RunnerLandingZone
266
- steps:
267
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
268
- - 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
269
-
270
- sbom:
271
- name: SBOM (CycloneDX)
272
- runs-on: RunnerLandingZone
273
- steps:
274
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
275
- - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
276
- with:
277
- format: cyclonedx-json
278
- artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
279
-
280
- # A bot pull request is opened with GITHUB_TOKEN, and GitHub delivers no workflow_run event
281
- # for the CI runs that follow (the run's actor is the bot). The router's CI-completion route
282
- # never sees them, so this job hands the verdict over from inside CI. workflow_dispatch is
283
- # one of the two events GITHUB_TOKEN may raise, so no App token is needed here, and the
284
- # router treats a dispatch as trusted. Human pull requests are left to the workflow_run
285
- # route, which does fire for them. The reconcile belt remains the hourly fallback.
286
- #
287
- # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
288
- # `actions: write`. A called workflow may not request a scope its caller withheld, and the
289
- # whole run fails to start with zero jobs and no annotation when it does.
290
- dispatch-merge-gate:
291
- name: Dispatch merge gate
292
- needs: [api, mutation-api, web, mutation-web]
293
- if: >
294
- always() &&
295
- github.event_name == 'pull_request' &&
296
- github.event.pull_request.head.repo.full_name == github.repository &&
297
- github.event.pull_request.draft == false &&
298
- github.event.pull_request.user.type == 'Bot'
299
- runs-on: RunnerLandingZone
300
- timeout-minutes: 5
301
- permissions:
302
- contents: read
303
- actions: write
304
- steps:
305
- - name: Dispatch the merge gate with this run's verdict
306
- env:
307
- GH_TOKEN: ${{ github.token }}
308
- REPO: ${{ github.repository }}
309
- REF: ${{ github.event.repository.default_branch }}
310
- PR_NUMBER: ${{ github.event.pull_request.number }}
311
- RUN_ID: ${{ github.run_id }}
312
- RESULTS: ${{ join(needs.*.result, ' ') }}
313
- run: |
314
- set -euo pipefail
315
- conclusion=success
316
- for result in $RESULTS; do
317
- case "$result" in
318
- failure) conclusion=failure; break ;;
319
- cancelled | timed_out) conclusion=cancelled ;;
320
- esac
321
- done
322
- # A cancelled run was superseded by a newer push, whose own run dispatches.
323
- if [ "$conclusion" = cancelled ]; then
324
- echo "CI was cancelled; not dispatching the merge gate."
325
- exit 0
326
- fi
327
- echo "Dispatching the merge gate for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
328
- gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
329
- -f operation=merge-gate -f pr-number="$PR_NUMBER" \
330
- -f ci-conclusion="$conclusion" -f ci-run-id="$RUN_ID"
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
+ name: "App: CI"
3
+
4
+ on:
5
+ pull_request:
6
+ schedule:
7
+ - cron: "0 6 * * 1"
8
+ workflow_dispatch:
9
+ workflow_call:
10
+ secrets:
11
+ NPM_REGISTRY_TOKEN:
12
+ required: false
13
+
14
+ env:
15
+ DOTNET_VERSION: "10.0.x"
16
+ NODE_VERSION: "22"
17
+ PNPM_VERSION: "10"
18
+ API_WORKING_DIRECTORY: apps/api
19
+ DOTNET_SOLUTION: App.slnx
20
+ UNIT_TEST_PROJECT: tests/App.UnitTests/App.UnitTests.csproj
21
+ PARITY_TEST_PROJECT: tests/App.ParityTests/App.ParityTests.csproj
22
+ INTEGRATION_TEST_PROJECT: tests/App.IntegrationTests/App.IntegrationTests.csproj
23
+ # Per-PR mutation gate. Stryker mutates only files changed since the PR base (--since),
24
+ # so a green unit suite that never actually pins behaviour fails here instead of shipping.
25
+ # dotnet-stryker is pinned in the api tools manifest (.config/dotnet-tools.json / dotnet-tools.json).
26
+ MUTATION_TARGET_PROJECT: src/App.Domain/App.Domain.csproj
27
+ MUTATION_TEST_PROJECT: tests/App.UnitTests/App.UnitTests.csproj
28
+ MUTATION_THRESHOLD: "60"
29
+ COVERAGE_SETTINGS: coverage.runsettings
30
+ COVERAGE_CHECK_COMMAND: node ../../scripts/check-coverage.mjs TestResults/merged/Cobertura.xml
31
+ MIGRATION_PROJECT: src/App.Infrastructure/App.Infrastructure.csproj
32
+ MIGRATION_STARTUP_PROJECT: src/App.Api/App.Api.csproj
33
+ SEED_PROJECT: src/App.Seed
34
+ PRIMARY_DATABASE: app
35
+ DATA_LAYER_DATABASE: numa_dl
36
+ # Derived per run rather than written down. The container is ephemeral, bound to a random
37
+ # host port and reachable only from this job, so the value protects nothing, but a literal
38
+ # password in a config file is a policy finding wherever it sits, and this file is public.
39
+ # It has to be a workflow-level expression: service containers receive their environment
40
+ # before any step runs, so a value generated in a step would arrive too late.
41
+ #
42
+ # The same expression is repeated inside the service's `options` above rather than read
43
+ # from here, because the `env` context is not available in `jobs.<id>.services` — only
44
+ # inside that service's own `env:`. Written as ${{ env.SQL_SA_PASSWORD }} there, the health
45
+ # check probes with an empty password and the service never reports healthy.
46
+ SQL_SA_PASSWORD: "Aa1!${{ github.run_id }}"
47
+ WEB_PACKAGE: "@app/web"
48
+ NPM_REGISTRY_URL: "//npm.pkg.github.com/"
49
+ TRUFFLEHOG_IMAGE: "trufflesecurity/trufflehog:latest"
50
+ SEMGREP_IMAGE: "semgrep/semgrep:1.168.0"
51
+ SBOM_ARTIFACT_NAME: app-sbom.cyclonedx.json
52
+ MIGRATION_CONTEXT: AppDbContext
53
+
54
+ permissions:
55
+ contents: read
56
+
57
+ concurrency:
58
+ group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
59
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
60
+
61
+ jobs:
62
+ api:
63
+ name: API (.NET)
64
+ if: github.event_name != 'schedule'
65
+ runs-on: RunnerLandingZone
66
+ timeout-minutes: 30
67
+ services:
68
+ sqlserver:
69
+ image: mcr.microsoft.com/mssql/server:2022-latest
70
+ env:
71
+ ACCEPT_EULA: "Y"
72
+ MSSQL_SA_PASSWORD: ${{ env.SQL_SA_PASSWORD }}
73
+ ports:
74
+ # Ephemeral host port. Four runners share this machine, so a fixed 1433 means the
75
+ # second concurrent job fails at Initialize containers with "Docker start fail with
76
+ # exit code 1". Steps read the assigned port from the job.services context.
77
+ - 1433
78
+ options: >-
79
+ --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Aa1!${{ github.run_id }}' -Q 'SELECT 1' -C || exit 1"
80
+ --health-interval 10s --health-timeout 5s --health-retries 12 --health-start-period 30s
81
+ defaults:
82
+ run:
83
+ working-directory: ${{ env.API_WORKING_DIRECTORY }}
84
+ steps:
85
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
86
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
87
+ with:
88
+ dotnet-version: ${{ env.DOTNET_VERSION }}
89
+ - name: Restore tools, solution, and build
90
+ run: |
91
+ dotnet tool restore
92
+ dotnet restore "$DOTNET_SOLUTION"
93
+ dotnet build "$DOTNET_SOLUTION" --no-restore -c Release
94
+ - name: Unit tests with coverage
95
+ run: dotnet test "$UNIT_TEST_PROJECT" --no-build -c Release --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
96
+ - name: Parity tests
97
+ run: dotnet test "$PARITY_TEST_PROJECT" --no-build -c Release
98
+ - name: Create test databases
99
+ run: |
100
+ for database in "$PRIMARY_DATABASE" "$DATA_LAYER_DATABASE"; do
101
+ 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];"
102
+ done
103
+ - name: Apply migrations and seed test data
104
+ env:
105
+ ConnectionStrings__App: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.PRIMARY_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
106
+ DataLayer__ConnectionString: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.DATA_LAYER_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
107
+ DATALAYER_SEED: "true"
108
+ run: |
109
+ dotnet ef database update -p "$MIGRATION_PROJECT" -s "$MIGRATION_STARTUP_PROJECT" --context "$MIGRATION_CONTEXT"
110
+ dotnet run --project "$SEED_PROJECT" --no-build -c Release
111
+ - name: Integration tests with coverage
112
+ env:
113
+ ConnectionStrings__App: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.PRIMARY_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
114
+ DataLayer__ConnectionString: Server=localhost,${{ job.services.sqlserver.ports['1433'] }};Database=${{ env.DATA_LAYER_DATABASE }};User Id=sa;Password=${{ env.SQL_SA_PASSWORD }};TrustServerCertificate=true
115
+ run: dotnet test "$INTEGRATION_TEST_PROJECT" --no-build -c Release --collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
116
+ - name: Enforce merged coverage floor
117
+ run: |
118
+ dotnet tool install --global dotnet-reportgenerator-globaltool
119
+ export PATH="$PATH:$HOME/.dotnet/tools" # ephemeral ARC runners do not have dotnet tools on PATH
120
+ reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:TestResults/merged -reporttypes:Cobertura
121
+ bash -c "$COVERAGE_CHECK_COMMAND"
122
+
123
+ mutation-api:
124
+ name: Mutation (.NET, diff)
125
+ if: github.event_name == 'pull_request'
126
+ runs-on: RunnerLandingZone
127
+ timeout-minutes: 25
128
+ defaults:
129
+ run:
130
+ working-directory: ${{ env.API_WORKING_DIRECTORY }}
131
+ steps:
132
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
133
+ with:
134
+ # Stryker --since diffs against the PR base; it needs history, not a shallow tip.
135
+ fetch-depth: 0
136
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
137
+ with:
138
+ dotnet-version: ${{ env.DOTNET_VERSION }}
139
+ - name: Detect backend changes
140
+ id: scope
141
+ working-directory: ${{ github.workspace }}
142
+ run: |
143
+ git fetch origin "$GITHUB_BASE_REF" --depth=1
144
+ if git diff --name-only "origin/$GITHUB_BASE_REF" HEAD | grep -qE "^${API_WORKING_DIRECTORY}/"; then
145
+ echo "run=true" >> "$GITHUB_OUTPUT"
146
+ else
147
+ echo "run=false" >> "$GITHUB_OUTPUT"
148
+ echo "No ${API_WORKING_DIRECTORY} changes in this PR; skipping .NET mutation."
149
+ fi
150
+ - name: Restore tools and build
151
+ if: steps.scope.outputs.run == 'true'
152
+ run: |
153
+ dotnet tool restore
154
+ dotnet build "$DOTNET_SOLUTION" -c Release
155
+ - name: Stryker.NET (changed files only)
156
+ if: steps.scope.outputs.run == 'true'
157
+ run: |
158
+ export PATH="$PATH:$HOME/.dotnet/tools" # ephemeral ARC runners do not have dotnet tools on PATH
159
+ dotnet stryker \
160
+ --project "$MUTATION_TARGET_PROJECT" \
161
+ --test-projects "$MUTATION_TEST_PROJECT" \
162
+ --since:"origin/$GITHUB_BASE_REF" \
163
+ --concurrency 2 \
164
+ --threshold-high "$MUTATION_THRESHOLD" \
165
+ --threshold-low "$MUTATION_THRESHOLD" \
166
+ --break-at "$MUTATION_THRESHOLD" \
167
+ --reporter progress --reporter json
168
+
169
+ web:
170
+ name: Web (Next.js)
171
+ if: github.event_name != 'schedule'
172
+ runs-on: RunnerLandingZone
173
+ timeout-minutes: 15
174
+ steps:
175
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
176
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
177
+ with:
178
+ version: ${{ env.PNPM_VERSION }}
179
+ run_install: false
180
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
181
+ with:
182
+ node-version: ${{ env.NODE_VERSION }}
183
+ cache: pnpm
184
+ cache-dependency-path: pnpm-lock.yaml
185
+ - name: Configure optional private registry authentication
186
+ env:
187
+ NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
188
+ run: |
189
+ if [ -n "$NPM_REGISTRY_TOKEN" ]; then
190
+ echo "${NPM_REGISTRY_URL}:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc
191
+ fi
192
+ - run: pnpm install --frozen-lockfile
193
+ - name: Lint, test, verify boundaries, and build
194
+ run: |
195
+ pnpm --filter "$WEB_PACKAGE" run lint
196
+ pnpm --filter "$WEB_PACKAGE" run test
197
+ pnpm --filter "$WEB_PACKAGE" run lint:fsd
198
+ pnpm --filter "$WEB_PACKAGE" run build
199
+
200
+ mutation-web:
201
+ name: Mutation (web, diff)
202
+ if: github.event_name == 'pull_request'
203
+ runs-on: RunnerLandingZone
204
+ timeout-minutes: 25
205
+ steps:
206
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
207
+ with:
208
+ fetch-depth: 0
209
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
210
+ with:
211
+ version: ${{ env.PNPM_VERSION }}
212
+ run_install: false
213
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
214
+ with:
215
+ node-version: ${{ env.NODE_VERSION }}
216
+ cache: pnpm
217
+ cache-dependency-path: pnpm-lock.yaml
218
+ - name: Detect web changes
219
+ id: scope
220
+ run: |
221
+ git fetch origin "$GITHUB_BASE_REF" --depth=1
222
+ if git diff --name-only "origin/$GITHUB_BASE_REF" HEAD | grep -qE "^apps/web/"; then
223
+ echo "run=true" >> "$GITHUB_OUTPUT"
224
+ else
225
+ echo "run=false" >> "$GITHUB_OUTPUT"
226
+ echo "No apps/web changes in this PR; skipping web mutation."
227
+ fi
228
+ - name: Configure optional private registry authentication
229
+ if: steps.scope.outputs.run == 'true'
230
+ env:
231
+ NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }}
232
+ run: |
233
+ if [ -n "$NPM_REGISTRY_TOKEN" ]; then
234
+ echo "${NPM_REGISTRY_URL}:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc
235
+ fi
236
+ - name: Install and run StrykerJS (changed files only)
237
+ if: steps.scope.outputs.run == 'true'
238
+ run: |
239
+ pnpm install --frozen-lockfile
240
+ pnpm --filter "$WEB_PACKAGE" exec stryker run --since="origin/$GITHUB_BASE_REF"
241
+
242
+ secret-scan:
243
+ name: Secret scan (TruffleHog)
244
+ runs-on: RunnerLandingZone
245
+ steps:
246
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
247
+ - run: docker run --rm -v "${{ github.workspace }}:/repo" "$TRUFFLEHOG_IMAGE" filesystem /repo --only-verified --fail --no-update
248
+
249
+ deps-and-iac-scan:
250
+ name: Dependencies and IaC (Trivy)
251
+ runs-on: RunnerLandingZone
252
+ steps:
253
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
254
+ - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
255
+ with:
256
+ scan-type: fs
257
+ scanners: vuln,misconfig,secret
258
+ severity: CRITICAL,HIGH
259
+ ignore-unfixed: true
260
+ exit-code: "1"
261
+ format: table
262
+
263
+ sast-scan:
264
+ name: SAST (Semgrep)
265
+ runs-on: RunnerLandingZone
266
+ steps:
267
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
268
+ - 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
269
+
270
+ sbom:
271
+ name: SBOM (CycloneDX)
272
+ runs-on: RunnerLandingZone
273
+ steps:
274
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
275
+ - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
276
+ with:
277
+ format: cyclonedx-json
278
+ artifact-name: ${{ env.SBOM_ARTIFACT_NAME }}
279
+
280
+ # A bot pull request is opened with GITHUB_TOKEN, and GitHub delivers no workflow_run event
281
+ # for the CI runs that follow (the run's actor is the bot). The router's CI-completion route
282
+ # never sees them, so this job hands the verdict over from inside CI. workflow_dispatch is
283
+ # one of the two events GITHUB_TOKEN may raise, so no App token is needed here, and the
284
+ # router treats a dispatch as trusted. Human pull requests are left to the workflow_run
285
+ # route, which does fire for them. The reconcile belt remains the hourly fallback.
286
+ #
287
+ # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
288
+ # `actions: write`. A called workflow may not request a scope its caller withheld, and the
289
+ # whole run fails to start with zero jobs and no annotation when it does.
290
+ dispatch-merge-gate:
291
+ name: Dispatch merge gate
292
+ needs: [api, mutation-api, web, mutation-web]
293
+ if: >
294
+ always() &&
295
+ github.event_name == 'pull_request' &&
296
+ github.event.pull_request.head.repo.full_name == github.repository &&
297
+ github.event.pull_request.draft == false &&
298
+ github.event.pull_request.user.type == 'Bot'
299
+ runs-on: RunnerLandingZone
300
+ timeout-minutes: 5
301
+ permissions:
302
+ contents: read
303
+ actions: write
304
+ steps:
305
+ - name: Dispatch the merge gate with this run's verdict
306
+ env:
307
+ GH_TOKEN: ${{ github.token }}
308
+ REPO: ${{ github.repository }}
309
+ REF: ${{ github.event.repository.default_branch }}
310
+ PR_NUMBER: ${{ github.event.pull_request.number }}
311
+ RUN_ID: ${{ github.run_id }}
312
+ RESULTS: ${{ join(needs.*.result, ' ') }}
313
+ run: |
314
+ set -euo pipefail
315
+ conclusion=success
316
+ for result in $RESULTS; do
317
+ case "$result" in
318
+ failure) conclusion=failure; break ;;
319
+ cancelled | timed_out) conclusion=cancelled ;;
320
+ esac
321
+ done
322
+ # A cancelled run was superseded by a newer push, whose own run dispatches.
323
+ if [ "$conclusion" = cancelled ]; then
324
+ echo "CI was cancelled; not dispatching the merge gate."
325
+ exit 0
326
+ fi
327
+ echo "Dispatching the merge gate for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
328
+ gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
329
+ -f operation=merge-gate -f pr-number="$PR_NUMBER" \
330
+ -f ci-conclusion="$conclusion" -f ci-run-id="$RUN_ID"