Repository navigation
455 lines (437 loc) · 23.6 KB
/
Copy pathci.yml
File metadata and controls
455 lines (437 loc) · 23.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
name: CI
# Runs the test suite on Linux and macOS, the Windows platform contract, the Linux lane sandbox
# tests, a parse check of every module, a boot smoke test, the Linux installer and the
# documentation freshness gate.
#
# WHAT MAKES THIS RUNNABLE AT ALL. Tests that read the LIVE FLEET off the machine they run on (the
# registry's declared repository paths, ~/.cloudflared, and a checkout declared in
# monitor/projects.json) SKIP WITH A STATED REASON when the fleet is absent, because a runner has
# one checkout at a path that changes per run and no fleet. They run in full on a fleet
# machine. A red test nobody can turn green is how a check gets switched off. Nothing is silently
# excluded: `npm test` runs every file, and the reasons appear in this job's log.
#
# Deliberately NOT here: a real sweep. A sweep over real repositories can block for hours on one
# of them, and a job that gets cancelled teaches nobody anything.
on:
# release.yml runs this file as its gate on a v* tag.
workflow_call:
secrets:
CW_RELEASE_SCOPE:
required: false
push:
# Branches only: a tag is tested by release.yml's call above, and a second run of the same tag
# here would take the same concurrency group and cancel it.
branches: ['**']
# Skip prose-only pushes: most pushes go to main, and a docs edit should not cost a full CI run.
# Test fixtures are markdown too, so they are re-included last (later patterns win).
paths:
- '**'
- '!**/*.md'
- '!docs/**'
- '**/test/fixtures/**'
# Every pull request runs in full, prose-only ones included: the docs job lives in this file, and a
# required check that never reports blocks the merge.
pull_request:
# A newer push to the same ref cancels the run it supersedes; only the latest commit's result is read,
# and every superseded minute is billed on a private repository.
concurrency:
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
# Every job reads the tree and publishes nothing; a token with write scopes here is the credential a
# poisoned dependency or a compromised action would move on.
permissions:
contents: read
jobs:
tests:
name: Test suite (${{ matrix.os }}, Node ${{ matrix.node }})
# Supported majors: 22 is the floor package.json engines.node declares (maintenance LTS until
# 2027-04-30) and 26 is the version CI, releases and the Action run. bin/test/node-floor.test.mjs holds the lowest version here,
# the engines floor and the CLI's refusal to one declaration, and requires every OS to run it.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
node: ['22', '26']
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
# Actions pinned to commit SHAs, not tags: a tag can be silently repointed by the action
# owner (the trivy-action / tj-actions compromise class). The trailing comment names the
# release each SHA was resolved from via `git ls-remote --tags`.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# the history guards read real history; a depth-1 clone makes them skip with the reason
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
# node:sqlite, zlib zstd and node --test's glob form are all used. The floor is real, not habitual.
node-version: ${{ matrix.node }}
# The release-name gates need the private redaction maps, which never enter the tree. They come
# from one secret, a base64 gzipped tar of the three maps, set from the sidecar with:
# tar -czf - -C <sidecar> monitor/release-redactions.json monitor/publish-redactions.json \
# identity/repo-identities.json | base64 | gh secret set CW_RELEASE_SCOPE
# This log is public. Every report those gates print masks the name it found, which
# bin/test/release-names-output.test.mjs holds. Pushes only: a pull request's code never runs
# with the maps.
- name: Load the release-name maps
# Linux only: the maps are about tree content, not platform, so one OS is enough to hold
# the secret; elsewhere the release-name gates skip and say so.
if: github.event_name == 'push' && runner.os == 'Linux'
env:
RELEASE_SCOPE: ${{ secrets.CW_RELEASE_SCOPE }}
run: |
if [ -z "$RELEASE_SCOPE" ]; then
echo "::notice::CW_RELEASE_SCOPE is not set, so the release-name gates skip and say so"
exit 0
fi
dir="$RUNNER_TEMP/release-scope"
mkdir -p "$dir"
printf '%s' "$RELEASE_SCOPE" | base64 -d | tar -xzf - -C "$dir"
{
echo "CW_RELEASE_REDACTIONS=$dir/monitor/release-redactions.json"
echo "CW_PUBLISH_REDACTIONS=$dir/monitor/publish-redactions.json"
echo "CW_REPO_IDENTITIES=$dir/identity/repo-identities.json"
} >> "$GITHUB_ENV"
# No `npm ci`. commitwork declares zero runtime dependencies, and this job is part of what
# keeps it that way — an install step would quietly absorb the day that changes.
- name: Run the suite
run: npm test
# The Linux lane sandbox, measured. The suite above runs without bwrap, so its sandbox tests skip
# with a reason; this job installs bubblewrap and passt and runs them for real, so open lanes
# reaching host loopback, the host's addresses or /run sockets is a red build here rather than
# a skip. A skip of a Linux confinement test in this job is a failure.
linux-sandbox:
name: Linux lane sandbox (bwrap + pasta)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '26'
- name: Install bubblewrap and passt
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends bubblewrap passt
# Ubuntu 24.04 can restrict unprivileged user namespaces through AppArmor. This job measures
# the sandbox, not the host policy, so it lifts the restriction and says so.
if [ "$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null)" = "1" ]; then
echo "::notice::lifting kernel.apparmor_restrict_unprivileged_userns for this job"
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --unshare-net --die-with-parent -- /bin/true
pasta --version
- name: Sandbox tests, with no Linux skip allowed
run: |
set -o pipefail
node --test bin/test/sandbox-netns-live.test.mjs bin/test/sandbox-net.test.mjs \
bin/test/sandbox-host.test.mjs bin/test/lane-env-confinement.test.mjs \
bin/test/sandbox-linux-devices.test.mjs 2>&1 | tee "$RUNNER_TEMP/sandbox.log"
if grep -E 'bwrap and pasta confinement is NOT verified|bwrap cannot create a namespace|pasta \(package passt\) is absent' "$RUNNER_TEMP/sandbox.log"; then
echo "::error::a Linux confinement test skipped in the job that exists to run it"
exit 1
fi
syntax:
name: Every module parses (Node ${{ matrix.node }})
strategy:
fail-fast: false
matrix:
node: ['22', '26']
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
# Cheap, and it catches the class of breakage that makes the suite above fail to even start,
# with a clearer message than a test runner gives for the same fault.
#
# workflows/ IS EXCLUDED, AND THE REASON IS THAT THE CHECK IS WRONG THERE, not the code.
# Those files are Workflow scripts, not Node modules: the runtime wraps the body in an async
# function, so `export const meta = {...}` followed by a top-level `return` or `await` is
# legal and `node --check` rejects it. Running it anyway would make this job red over a
# correct file, and a check that cries wolf gets switched off, which is worse than not having
# it. Excluded by path, declared here, and the count is printed so the exclusion cannot grow
# unnoticed.
- name: node --check every module
run: |
fail=0 checked=0 skipped=0
# An assignment, not a pipe in the for list: bash -e fails the step when git does, where a
# failing git inside `$(git … | grep …)` yielded zero files and a green job.
modules=$(git ls-files -- '*.mjs' ':(exclude,glob)**/node_modules/**')
for f in $modules; do
case "$f" in
workflows/*) skipped=$((skipped+1)); continue ;;
esac
checked=$((checked+1))
node --check "$f" || { echo "::error file=$f::does not parse"; fail=1; }
done
echo "checked $checked module(s); skipped $skipped Workflow script(s) under workflows/ — see the note in this file"
[ "$checked" -gt 0 ] || { echo "::error::no module was checked"; exit 1; }
exit $fail
windows:
name: Windows portability (Node ${{ matrix.node }})
strategy:
fail-fast: false
matrix:
node: ['22', '26']
runs-on: windows-latest
# 30, not the 20 the Linux jobs use: a Windows runner's process spawn is markedly slower and
# this job runs the suite twice — once serially for verdicts, once for side effects.
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
# WHY THIS JOB EXISTS. Windows defects are invisible on Linux and rarely findable by
# reading: separator mismatches that make the structural gates flag everything, CRLF that
# silently disables line-based parsers, `import(absolutePath)` that stops a suite from
# running at all, and `npm` (which is `npm.cmd` here) being unspawnable. Code that is never
# EXECUTED on its second platform collects them.
#
# WHY IT IS NOT `npm test`, YET. The full suite is not reproducible run to run: test files
# that read live ambient state (`reports/`, the ledgers, the lock files) instead of fixtures
# move the failure count between runs. Gating on that count would be red forever, and a
# check that cries wolf gets switched off. So this job gates on what IS deterministic, and
# the full Windows suite joins it once those tests run on fixtures.
# 1. Every module parses. The Linux job does this with a shell loop; PowerShell needs its own,
# and having it here is not duplication — it is the check catching Windows-only syntax and
# path faults at the cheapest possible point.
- name: node --check every module
shell: pwsh
run: |
$fail = 0; $checked = 0; $skipped = 0
foreach ($f in (git ls-files '*.mjs')) {
if ($f -like 'workflows/*') { $skipped++; continue } # Workflow scripts, not modules — see the Linux job
$checked++
node --check $f
if ($LASTEXITCODE -ne 0) { Write-Output "::error file=$f::does not parse"; $fail = 1 }
}
Write-Output "checked $checked module(s); skipped $skipped Workflow script(s)"
if ($checked -eq 0) { Write-Output "::error::no module was checked"; exit 1 }
exit $fail
# 2. The platform contract itself. These suites ARE the Windows contract — shell resolution,
# the Node repo walk, the process-tree kill, batch-shim spawning, the path hazards, the
# listener lens, the installer's manager shapes, and the two structural gates. All are
# fixture-driven and deterministic, so this list must be GREEN.
- name: The Windows contract holds
shell: pwsh
run: |
node --test --test-concurrency=1 `
lib/test/posix-shell.test.mjs `
lib/test/proc-tree.test.mjs `
lib/test/repo-walk.test.mjs `
lib/test/win-spawn.test.mjs `
lib/test/win-path-safety.test.mjs `
lib/test/untrusted-text.test.mjs `
monitor/test/port-bind-windows.test.mjs `
monitor/test/port-bind.test.mjs `
bin/test/install-catalog-managers.test.mjs `
bin/test/install-catalog.test.mjs `
bin/test/esm-specifier-shape.test.mjs `
bin/test/tracked-imports.test.mjs `
bin/test/docs-doctor.test.mjs `
bin/test/measured.test.mjs `
bin/test/test-run.test.mjs `
bin/test/scan-exclusions-regex.test.mjs
# 3. THE SUITE MUST NOT MODIFY THE WORKING TREE. Cheap and deterministic. Two Windows-only
# ways a test dirties the tree:
# - a Windows path interpolated unquoted into a POSIX shell loses its backslashes,
# becomes RELATIVE, and the redirect writes into the repository root;
# - cmd.exe reads the `>` of an arrow function `()=>{}` on a command line as an output
# redirection and creates a file literally named `{}` in the repository root.
# A test that dirties the tree is a defect regardless of whether it passes.
#
# NOTE WHAT THIS STEP CANNOT SEE: `reports/` and `.claude/` are BOTH GITIGNORED, so
# `git status` is structurally incapable of reporting pollution there, such as production
# code spawned by tests appending to the live `reports/perf-feedback.jsonl`. The ignored
# paths are covered instead by bin/test-run.mjs, which fingerprints them around the run
# and fails if one moved; the two checks together cover tracked AND ignored, and neither
# alone does.
- name: The suite leaves the tree clean
shell: pwsh
run: |
# Default concurrency, not the runner's serial mode: this step reads SIDE EFFECTS, never
# assertions, so results are discarded and there is nothing for parallelism to
# destabilise. The serial run belongs in the step above, where the verdicts matter.
node --test "bin/**/*.test.mjs" "lib/**/*.test.mjs" 2>&1 | Out-Null
$dirty = (git status --porcelain)
if ($dirty) {
Write-Output "::error::the test suite modified the working tree:"
$dirty | ForEach-Object { Write-Output " $_" }
exit 1
}
Write-Output "working tree clean after the suite"
# 4. The install path a Windows operator actually walks. `setup` with no TTY and no --yes is
# report-only by contract, so this asserts the REPORT rather than installing anything: it
# must run, and it must resolve a POSIX shell from the Git that the runner already has.
# `doctor` is where a missing shell becomes visible, before it shows up as unexplained
# check failures.
- name: The install path reports honestly
shell: pwsh
run: |
node bin/commitwork.mjs setup
$out = node bin/commitwork.mjs doctor --manifest security-baseline 2>&1 | Out-String
Write-Output $out
if ($out -notmatch 'POSIX shell') { Write-Output '::error::doctor does not report the POSIX shell prerequisite'; exit 1 }
if ($out -match '(?m)^\s*✗ POSIX shell') {
Write-Output '::error::no POSIX shell resolved on a runner that has Git for Windows — the git-adjacent lookup regressed'
exit 1
}
smoke:
name: It starts
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '26'
# The suite above is entirely unit-level: it proves functions behave, never that the CLI
# runs or the panel serves. commitwork has no compile step — zero runtime dependencies is
# the point — so "does it build" has no meaning here and "does it boot" is the equivalent
# question.
#
# bin/smoke.mjs asserts the panel's AUTH BOUNDARY, not merely that something responded:
# the operator port must serve a real HTML document and the published port must refuse an
# unauthenticated request. The second is the one worth having — a smoke test that only
# checks for a response passes just as happily when auth has been switched off, on the port
# a tunnel publishes. The smoke pins an absent auth store, so the panel it boots is
# unbootstrapped on every runner and the refusal is the 503 bootstrap answer; a box with an
# operator account answers 401.
- name: CLI resolves and the panel boots
run: node bin/smoke.mjs
installer:
name: Installer on Linux
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '26'
# On Linux `commitwork setup` installs through Homebrew when brew is on PATH, else pipx, npm,
# cargo, gem, composer and go, with apt or dnf after them. This job exercises the pipx route
# end to end: a report-only run must exit 0 without a prompt, and a real install must leave
# a tool that runs and that doctor then sees.
- name: Report-only setup never prompts
run: mkdir -p "$HOME" && node bin/commitwork.mjs setup < /dev/null
env:
HOME: ${{ runner.temp }}/home
- name: Install one scanner through pipx
run: |
node bin/commitwork.mjs setup --yes --only flawfinder
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: The installed scanner runs and doctor sees it
run: |
flawfinder --version
report=$(node bin/commitwork.mjs doctor --manifest security-baseline)
printf '%s\n' "$report" | grep -E '✓ flawfinder'
docs:
name: Documentation freshness
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# docs-doctor compares each durable doc's `verified-against` stamp against the commits
# that have touched it since. With the default shallow clone it cannot see them and
# reports drift that does not exist.
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '26'
# THIS JOB GATES ON GREY, WARNS ON ORANGE, AND THE ASYMMETRY IS DELIBERATE.
#
# docs-doctor exits 1 for orange (a stamped doc whose stamp is behind) and 2 for grey-only
# (a durable doc with NO stamp at all).
#
# On a tree where several writers commit concurrently, a stamp falls behind its doc
# routinely and transiently. Gating on orange would put CI red on a condition that resolves
# itself and train everyone to ignore it: a check that cries wolf gets switched off, which is
# worse than not having it. Grey is gated because grey is the worse class: an unstamped
# durable doc has never been verified AT ALL. Orange ratchets to a gate when orange stops
# being the normal condition of a shared tree; the gate exists and is runnable before it is
# enforced, exactly as quality-gates does for qg-boot-test-pass.
#
# `rc=0` and `|| rc=$?` ARE LOAD-BEARING. GitHub runs `run:` steps as `bash -e {0}`, so a
# bare invocation aborts the step the instant docs-doctor exits non-zero and the case
# statement below is never reached — the job would fail on orange and reintroduce exactly
# the problem this shape exists to avoid. `|| ...` suppresses -e; initialising rc keeps a
# green run (which never assigns it) from falling through to the catch-all.
- name: docs-doctor — grey blocks, orange warns
run: |
rc=0
node bin/docs-doctor.mjs || rc=$?
case "$rc" in
0) echo "docs: all durable docs stamped and fresh" ;;
1) echo "::warning title=Documentation drift::orange — one or more stamps are behind their doc. Re-verify and refresh the stamp. Not blocking yet." ;;
2) echo "::error title=Unstamped durable doc::grey — a durable doc carries no verified-against stamp, so it has never been verified. Blocking."; exit 1 ;;
*) echo "::error title=docs-doctor failed::exited $rc, which is neither 0, 1 nor 2 — treating an unrecognised outcome as failure rather than assuming it passed."; exit 1 ;;
esac
dependencies:
name: Dependency advisories
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '26'
# Reads the committed lockfile and the registry; installs nothing. Exit 21 is a shipped
# advisory at high or above that an upgrade or override can clear; 22 is a result nobody could
# measure, which fails the job rather than reading as clear. Advisories in devDependencies are
# printed and do not gate: they do not ship.
- name: Audit shipped dependencies
run: node bin/commitwork.mjs deps . --audit-gate high --no-record
# A Dependabot pull request merges itself only when every job above passed and
# .github/dependency-updates.json permits every dependency it moves. Anything else stays open for
# review. The decision is bin/dependency-policy-check.mjs, the same policy `commitwork deps` reads.
# The author check reads the pull request's own author, not github.actor: whoever re-runs a job
# becomes the actor, and an actor test is spoofable that way (zizmor bot-conditions).
dependabot-merge:
name: Merge a Dependabot update the policy allows
needs: [tests, linux-sandbox, syntax, windows, smoke, installer, docs, dependencies]
if: ${{ !failure() && !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
pull-requests: write
steps:
- id: meta
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '26'
- id: policy
name: Ask the dependency policy
env:
DEP_UPDATES_JSON: ${{ steps.meta.outputs.updated-dependencies-json }}
DEP_GHSA_ID: ${{ steps.meta.outputs.ghsa-id }}
run: node bin/dependency-policy-check.mjs --github-output
- name: Merge
if: steps.policy.outputs.allowed == 'true'
env:
GH_TOKEN: ${{ github.token }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr merge --squash "$PR_URL"