@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.
- package/README.md +1 -1
- package/dist/catalog-installation.js +5 -0
- package/dist/catalog-listing.js +6 -1
- package/dist/index.js +27 -4
- package/dist/template-bundles.d.ts +21 -0
- package/dist/template-bundles.js +108 -0
- package/dist/workflow-catalog.d.ts +5 -1
- package/dist/workflow-catalog.js +8 -0
- package/loops/actions/report-workflow-errors/action.yml +63 -16
- package/loops/actions/report-workflow-errors/report-policy.cjs +89 -5
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +18 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +776 -117
- package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +171 -0
- package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +178 -0
- package/loops/templates/quality-gates/.jscpd.json +36 -0
- package/loops/templates/quality-gates/.jscpd.json.md +7 -0
- package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
- package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
- package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
- package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
- package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
- package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
- package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
- package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
- package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
- package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
- package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
- package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
- package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
- package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
- package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
- package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
- package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
- package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
- 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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
178
|
+
MSSQL_SA_PASSWORD: "Aa1!${{ github.run_id }}"
|
|
75
179
|
ports:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
dotnet
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
run: dotnet test "$UNIT_TEST_PROJECT" --no-build -c Release
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
114
|
-
env
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
#
|
|
286
|
-
#
|
|
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
|
|
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
|
|
303
|
-
#
|
|
304
|
-
#
|
|
305
|
-
#
|
|
306
|
-
#
|
|
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-
|
|
313
|
-
name: Dispatch merge
|
|
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
|
-
|
|
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
|