-
Notifications
You must be signed in to change notification settings - Fork 1
685 lines (625 loc) · 37.8 KB
/
Copy pathci.yml
File metadata and controls
685 lines (625 loc) · 37.8 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
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-pnpm-node
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm type-check
- name: Type check tests
run: pnpm type-check:tests
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [22, 24]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-pnpm-node
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Run tests
run: pnpm test
test-integration:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-pnpm-node
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Run integration tests
run: pnpm --filter @cachekit-io/cachekit test:integration
env:
REDIS_URL: redis://localhost:6379
CI: true
coverage:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for Codecov OIDC
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-pnpm-node
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Test with coverage
run: pnpm test:coverage
# fail_ci_if_error is also the wrapper's signature-enforcement switch: with
# false, a failed `gpg --verify` of the CLI's SHA256SUM is only logged and
# the unverified binary still executes — which v6.0.0 did on every run after
# Codecov moved its public key off the keybase account v6.0.0 fetched it
# from (codecov/codecov-action#1956, 2026-06-07). Keep it true so the step
# aborts before exec; continue-on-error keeps the upload from reddening CI.
# When this step is orange, read the log: "Could not verify signature" is
# the guard working (never silence it); an upload error (token, network)
# only loses that upload. Codecov requires a token on any same-repo branch
# (its error says "protected"). use_oidc mints one per run via the job's
# id-token: write, so no stored secret. Forks never attempt OIDC and upload
# tokenless; "Token required" on a same-repo run means OIDC did not mint.
- name: Upload coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
continue-on-error: true
with:
files: ./packages/cachekit/coverage/lcov.info
use_oidc: true
fail_ci_if_error: true
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-pnpm-node
# Holds where renovate.json's approval gate cannot: vulnerability-
# remediation updates bypass that gate (why: CONTRIBUTING.md). Runs before
# `pnpm install` so no install script can rewrite the file first.
- name: Verify overrides match the declared set
run: |
set -euo pipefail
# Reads `pnpm config get overrides --json` on stdin. Fails unless every
# override key is declared below with the shape pnpm reports for it.
guard() (
# Every override, "<package> <shape>": "<N" for a bounded pin
# (`>=floor <N`, the upper-bound major), "floor" for an unbounded
# `>=floor`. Adding, removing or reshaping an override means editing
# this list: that edit IS the human decision the entry represents.
expected='brace-expansion@2 <3
brace-expansion@5 floor
flatted floor
js-yaml <5
lodash floor
picomatch floor
postcss floor
protobufjs <8
sharp floor
uuid <12
vite floor
yaml floor'
expected=$(printf '%s\n' "${expected}" | sort)
# Every key is reported. A bounded value counts only as a plain
# `>=floor <N`, floor and bound at most three parts, the bound written
# <N, <N.0 or <N.0.0. Anything else — an exact pin, `<12.1` (admits
# 12.0.x), `<12.0.0.0` (invalid, so semver drops it), a `||` union —
# reports as unparsed and fails the comparison. A key with whitespace
# could forge extra lines, and bash drops a NUL byte from $(…), so a
# key with anything but printable ASCII fails. Explicit `||`: set -e does not
# hold inside the self-test's `if guard` below.
found=$(node -e '
const o = JSON.parse(require("fs").readFileSync(0, "utf8").trim() || "{}") ?? {};
for (const [k, v] of Object.entries(o)) {
if (/[^\x21-\x7e]/.test(k)) throw new Error("override key is not printable ASCII: " + JSON.stringify(k));
// A non-string (String(["x"]) is "x") matches neither shape.
const s = typeof v === "string" ? v : "";
const m = /^>=\d+(?:\.\d+){0,2}\s+<(\d+)(?:\.0){0,2}$/.exec(s);
const shape = m ? "<" + m[1] : /^>=\d+(?:\.\d+){0,2}$/.test(s) ? "floor" : "unparsed " + JSON.stringify(v);
console.log(k + " " + shape);
}' | sort) || { echo "::error::Could not read the overrides pnpm reports; failing closed."; exit 1; }
if [ "${found}" != "${expected}" ]; then
echo "::error::Overrides in pnpm-workspace.yaml no longer match the set this check declares. Every override is a deliberate constraint, not drift (see the notes beside each pin, and CONTRIBUTING.md). To add, remove, reshape or cross a major on one, edit it in a hand-written commit that says why and update the expected list in this step."
echo "--- expected ---"; printf '%s\n' "${expected}"
echo "--- found ---"; printf '%s\n' "${found:-(none — pnpm reports no overrides)}" | sed 's/^/ /'
exit 1
fi
echo "Overrides verified (package shape):"
printf '%s\n' "${found}" | sed 's/^/ /'
)
overrides=$(pnpm config get overrides --json)
guard <<<"${overrides}"
# Self-test: each fixture is one edit to the real overrides (which just
# passed) that the guard must fail. Add the shape here when you close a hole.
# Fixtures target entries with no planned change (protobufjs, vite).
fail=0
while IFS= read -r edit; do
[ -n "${edit}" ] || continue
fixture=$(jq -c "${edit}" <<<"${overrides}")
if guard <<<"${fixture}" >/dev/null 2>&1; then
echo "::error::Override guard self-test: the guard passed a fixture it must fail: ${edit}. If the fixture targets an override you just changed, retarget it."
fail=1
fi
done <<'FIXTURES'
. + {"minimatch>brace-expansion": "5.0.8"}
. + {"left-pad": ">=1.3.0"}
. + {"left pad": ">=1.3.0"}
del(.protobufjs) + {"protobufjs\u0000": ">=7.6.5 <8"}
del(.protobufjs)
.protobufjs = ">=7.6.5 <9"
.protobufjs = ">=7.6.5"
.protobufjs = ">=7.6.5 <8.1"
.protobufjs = ">=7.6.5 <8.0.0.0"
.protobufjs = ">=7.6.5 <8 || >=9"
.protobufjs = [">=7.6.5 <8"]
.vite = "8.0.5"
.vite = "^8.0.5"
.vite = ">=8.0.5 <9"
FIXTURES
[ "${fail}" -eq 0 ] || exit 1
echo "Override guard self-test: every fixture failed as it must."
# pnpm-workspace.yaml ignores GHSA-mh99-v99m-4gvg graph-wide (advisory
# metadata lags the 2.x backport). That is only sound while every resolved
# brace-expansion sits on a floor-pinned line — 2.x or 5.x. A third major
# would be silently un-audited by the step below, so fail here instead of
# letting the ignore suppress a real advisory. Enforces the SAFETY
# INVARIANT documented beside the ignore; delete both together. Reads only
# the committed lockfile, so it runs before `pnpm install` for the same
# reason as the step above.
- name: Verify brace-expansion advisory-ignore invariant
run: |
set -euo pipefail
# `|| true`: no match makes grep exit 1, which under pipefail would
# abort the step with no message. Let the empty case reach the
# explicit branch below so the failure explains itself.
majors=$(grep -oE '^ brace-expansion@[0-9]+\.' pnpm-lock.yaml \
| sed -E 's#.*@([0-9]+)\.#\1#' | sort -u || true)
if [ -z "${majors}" ]; then
echo "::error::brace-expansion no longer resolves in pnpm-lock.yaml. The GHSA-mh99-v99m-4gvg ignore and its overrides in pnpm-workspace.yaml are now dead config — remove them."
exit 1
fi
unexpected=$(printf '%s\n' "${majors}" | grep -vxE '2|5' || true)
if [ -n "${unexpected}" ]; then
echo "::error::brace-expansion major(s) outside the floor-pinned 2.x/5.x lines resolved: $(printf '%s' "${unexpected}" | tr '\n' ' '). The graph-wide GHSA-mh99-v99m-4gvg ignore in pnpm-workspace.yaml no longer holds — floor-pin the new line via overrides, or drop the ignore and re-justify."
exit 1
fi
echo "brace-expansion majors resolved: $(printf '%s' "${majors}" | tr '\n' ' ')— invariant holds"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Audit production dependencies (blocking)
run: pnpm audit --prod --audit-level=high
# Production deps are the blocking gate (step above). Dev-only transitive
# advisories (e.g. tmp <0.2.6 path traversal, build-time tooling) are
# reported for visibility but must not block CI — they ship to no user.
# No --audit-level here on purpose: this step cannot fail the build, so a
# severity floor buys nothing and only hides findings. It used to inherit
# `high`, which is why the medium uuid advisory (GHSA-w5hq-g745-h8pq) was
# invisible to CI until someone read the Dependabot list by hand — and
# transitive advisories have no bot, so this step is the discovery lane.
- name: Audit all dependencies (non-blocking)
run: pnpm audit
continue-on-error: true
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Audit Cargo dependencies
working-directory: packages/cachekit-core-ts
run: cargo audit
workers:
name: Workers lane (wasm + workerd)
runs-on: ubuntu-latest
env:
# Must match the wasm-bindgen version pinned in the crate's Cargo.lock;
# scripts/build.sh fails loudly on drift.
WASM_BINDGEN_VERSION: 0.2.121
BINARYEN_VERSION: '131'
# SHA-256 of the release tarballs above (x86_64-linux assets) — the
# install step refuses to run binaries from a replaced upstream asset.
WASM_BINDGEN_SHA256: 3039f38f65fe237b640cf06a140c919ca8d717ec5012146d145d3f27bb4d6b28 # pragma: allowlist secret
BINARYEN_SHA256: b5bf1f0eaf17c63ee588ff7a5954dc8f6ce2c26989051c66f24dfe9ece3e46db # pragma: allowlist secret
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-pnpm-node
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable
with:
# Pinned (not `stable`) so the published wasm artifact doesn't drift
# with the runner's toolchain; bump deliberately alongside the
# size-budget check in scripts/build.sh.
toolchain: 1.97.1
targets: wasm32-unknown-unknown
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: packages/cachekit-core-wasm
- name: Install wasm-bindgen + wasm-opt (pinned prebuilts, checksummed)
run: |
mkdir -p "$HOME/.local/bin"
curl -sSfL -o /tmp/wasm-bindgen.tar.gz "https://github.com/wasm-bindgen/wasm-bindgen/releases/download/${WASM_BINDGEN_VERSION}/wasm-bindgen-${WASM_BINDGEN_VERSION}-x86_64-unknown-linux-musl.tar.gz"
echo "${WASM_BINDGEN_SHA256} /tmp/wasm-bindgen.tar.gz" | sha256sum -c -
tar xzf /tmp/wasm-bindgen.tar.gz -C /tmp
cp "/tmp/wasm-bindgen-${WASM_BINDGEN_VERSION}-x86_64-unknown-linux-musl/wasm-bindgen" "$HOME/.local/bin/"
curl -sSfL -o /tmp/binaryen.tar.gz "https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/binaryen-version_${BINARYEN_VERSION}-x86_64-linux.tar.gz"
echo "${BINARYEN_SHA256} /tmp/binaryen.tar.gz" | sha256sum -c -
tar xzf /tmp/binaryen.tar.gz -C /tmp
cp "/tmp/binaryen-version_${BINARYEN_VERSION}/bin/wasm-opt" "$HOME/.local/bin/"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build wasm artifact (size budget + API drift asserted)
run: pnpm --filter @cachekit-io/cachekit-core-wasm build:wasm
- name: Build SDK
run: pnpm --filter @cachekit-io/cachekit build
- name: Workers bundle guard (no node:*, NAPI, ioredis, prom-client)
run: pnpm --filter @cachekit-io/cachekit check:workers-bundle
- name: Workers tests (protocol vectors + smoke inside workerd)
run: pnpm --filter @cachekit-io/cachekit test:workers
smoke-test:
name: Package smoke test (ESM + CJS)
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-pnpm-node
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Verify ESM import
run: |
node --input-type=module -e "
import { createCache, ConfigurationError } from './packages/cachekit/dist/index.js';
const checks = [
['createCache', typeof createCache === 'function'],
['createCache.minimal', typeof createCache.minimal === 'function'],
['createCache.production', typeof createCache.production === 'function'],
['createCache.secure', typeof createCache.secure === 'function'],
['createCache.io', typeof createCache.io === 'function'],
['ConfigurationError', typeof ConfigurationError === 'function'],
];
const failed = checks.filter(([, ok]) => !ok);
if (failed.length) {
console.error('ESM export check failed:', failed.map(([n]) => n));
process.exit(1);
}
console.log('ESM: all exports verified');
"
- name: Verify CJS require
run: |
node -e "
const m = require('./packages/cachekit/dist/cjs/index.js');
const checks = [
['createCache', typeof m.createCache === 'function'],
['createCache.minimal', typeof m.createCache.minimal === 'function'],
['createCache.production', typeof m.createCache.production === 'function'],
['createCache.secure', typeof m.createCache.secure === 'function'],
['createCache.io', typeof m.createCache.io === 'function'],
['ConfigurationError', typeof m.ConfigurationError === 'function'],
];
const failed = checks.filter(([, ok]) => !ok);
if (failed.length) {
console.error('CJS export check failed:', failed.map(([n]) => n));
process.exit(1);
}
console.log('CJS: all exports verified');
"
# This repo is public and forkable, so every job runs on GitHub-hosted
# runners, never on self-hosted ones. Allow-list, not deny-list: a deny-list
# cannot see a label it has no name for. The programs are the heredocs
# below; their regression fixtures, .github/scripts/runner-drift-guard-selftest.sh,
# fail the job whenever a program gets one wrong. This is drift protection
# for maintainers, not a fork-PR control: a fork PR runs its own copy of this
# workflow.
runner-drift-guard:
name: Runner drift guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# The programs stay in this workflow file on purpose: GITHUB_TOKEN cannot
# write .github/workflows/, so a `contents: write` job (release-please) or
# a compromised action could rewrite a script under .github/scripts/ but
# not these heredocs. They are written to temp files so the self-test and
# the scan run the same bytes.
- name: Write the guard programs
run: |
cat > "$RUNNER_TEMP/runner-drift-guard.awk" <<'AWK'
# Allow-list, fail closed: every runs-on value, and every os or
# runner value inside a matrix block, must be one of the exact
# hosted labels in HOSTED or the matrix.os expression. This is a
# line scanner, not a YAML parser: a line that could hide a runner
# label from it is an error, never a skip. Exact labels, not a
# hosted-prefix pattern (a prefix admits any self-hosted label named
# ubuntu-<anything>) and not a copy of GitHub's image table (it
# rots): adopting another hosted image is a deliberate edit to HOSTED.
function fail(file, line, msg) {
if (gated) msg = msg " (if this line is | or > text, it was read as YAML because line " noskip " holds a [ or { that is not a flat one-line list, an empty {} or an expression's double braces)"
printf "::error file=%s,line=%d::%s\n", file, line, msg
bad = 1
}
# The advice fits the value: only one unknown plain label is fixed by
# adding it to HOSTED. self-hosted, or a runs-on list of two or more
# labels (a selector a runner must match in full, which no
# GitHub-hosted runner does), is named as picking a self-hosted runner.
function label(v, item, selector) {
if (v == "") fail(FILENAME, FNR, "runner value is not whole on this line (written below the key, wrapped, or cut by a # inside a quote) — write it on the key's line")
else if (item == "") fail(FILENAME, FNR, "runner value \"" v "\" is not a plain label the drift guard can check — write a hosted label, or $" "{{ matrix.os }} over a static os axis")
else if (selector || item == "self-hosted") fail(FILENAME, FNR, "runner value \"" (selector ? v : item) "\" selects a self-hosted runner — this public repo runs only on GitHub-hosted runners; write one GitHub-hosted label")
else fail(FILENAME, FNR, "runner value \"" item "\" is not an allow-listed GitHub-hosted label — this public repo runs only on GitHub-hosted runners; adopting a new hosted image means adding its label to HOSTED in the runner-drift-guard job of ci.yml")
}
# True for a uses: line the scan cannot clear: a value not whole on
# the line (below the key, a block scalar, an alias, an open quote),
# a value holding a backslash (an escape in a double-quoted value can
# spell any path, and no uses: reference needs one), or a reusable
# workflow in another repo, whose runs-on this scan never reads.
# Same-repo ./.github/workflows/ and $/.github/workflows/ callees are
# scanned.
function callee( v) {
if ($0 !~ /^[[:space:]]*(-[[:space:]]*)?["']?uses["']?[[:space:]]*:/) return 0
v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v)
return v == "" || v ~ /^[>|*]/ || v ~ /\\/ || gsub(/"/, "&", v) % 2 || gsub(/'/, "&", v) % 2 ||
v ~ /\/\.github\/workflows\// && v !~ /^["']?[.$]\/\.github\/workflows\//
}
# Carries a flow collection or quoted scalar left open at the end of
# a line onto the next: flow counts the open [ and {, quote holds the
# open quote character. A quote or bracket opens only where a value
# starts (line start, after "- ", ": ", an anchor or a tag, or inside
# a flow), so the ' in a plain scalar like don't opens nothing. A #
# after whitespace, outside a quote, starts a comment: cut holds its
# column, or one past the end. It reads every line but block-scalar
# text. Its state can be misread, so whether a | or > line opens a
# block scalar also rests on the quote refusal and the noskip gate.
function scan(s, i, c, start) {
start = 1; cut = length(s) + 1
for (i = 1; i <= length(s); i++) {
c = substr(s, i, 1)
if (quote == "'") { if (c == "'") { if (substr(s, i + 1, 1) == "'") i++; else quote = "" } }
else if (quote != "") { if (c == "\\") i++; else if (c == "\"") quote = "" }
else if (c == " " || c == "\t") continue
else if (c == "#" && (i == 1 || substr(s, i - 1, 1) ~ /[ \t]/)) { cut = i; break }
else if (start && (c == "&" || c == "!")) { while (i < length(s) && substr(s, i + 1, 1) !~ /[ \t]/) i++ }
else if (start && (c == "\"" || c == "'")) { quote = c; start = 0 }
else if ((start || flow) && (c == "[" || c == "{")) { flow++; start = 1 }
else if (flow && (c == "]" || c == "}")) { flow--; start = 0 }
else if (flow && c == ",") start = 1
else if (c == ":") start = flow || i == length(s) || substr(s, i + 1, 1) ~ /[ \t]/
else if (!(start && (c == "-" || c == "?") && (i == length(s) || substr(s, i + 1, 1) ~ /[ \t]/))) start = 0
}
}
# HOSTED is a set of exact labels, one word each: add a hosted
# image's label to the list, never a pattern.
BEGIN { split("ubuntu-latest macos-latest windows-latest", h); for (i in h) HOSTED[h[i]] = 1 }
FNR == 1 { matrix_indent = strategy_indent = value_indent = armed = block = gtext = axis = -1; in_jobs = flow = noskip = gated = 0; quote = "" }
# The text of a block scalar (the lines deeper than its | or > key)
# is a string, never YAML: no rule reads it, so a script line that
# says matrix: arms nothing and a ' in it opens nothing. The skip is
# only as safe as knowing where that text starts, so the noskip gate
# below turns it off for the rest of a file once a line holds a bracket
# it cannot vouch for. gtext holds the key indent of a | or > seen
# while the skip is off, and an error on the text below it says why
# that text was read as YAML.
# A # starts a YAML comment only at the start of a line or after
# whitespace; ubuntu-latest#x is one plain scalar. The strip cannot
# tell a quoted " #" from a comment, so the refusal rules that look
# for a flow key or an expression read lraw, the unstripped line, and
# the block-scalar header is matched on lraw as scan() cut it.
# A CR, NEL, LS or PS left inside a line is a YAML line break that
# awk does not split on, so the guard would read two lines as one.
{ gated = 0; sub(/\r$/, ""); if (index($0, "\r") || index($0, "\302\205") || index($0, "\342\200\250") || index($0, "\342\200\251")) fail(FILENAME, FNR, "line holds a CR, NEL (U+0085), LS (U+2028) or PS (U+2029) inside it, which YAML reads as a line break — delete it, or break the line with LF") }
{ lraw = tolower($0); sub(/(^|[[:space:]])#.*/, ""); $0 = tolower($0) }
/^[[:space:]]*$/ { next }
{ match($0, /^[[:space:]]*/); if (block >= 0 && RLENGTH > block) next; block = -1; gated = gtext >= 0 && RLENGTH > gtext; if (!gated) gtext = -1 }
# A bare strategy: or matrix: key holds the block below it, so the
# next line must be deeper; one that is not continues a flow the key
# sits in, and would end the block before its first line. A line
# inside a flow collection or quoted scalar left open above continues
# that value however it is indented, so it ends no block.
{
match($0, /^[[:space:]]*(-[[:space:]]*)?/)
indent = RLENGTH
if (armed >= 0 && indent <= armed) fail(FILENAME, FNR, "this line is not indented under the strategy: or matrix: key above it — keep that key bare over an indented block, not inside a flow map")
armed = -1
# The first line under a bare matrix: sets the axis column.
if (axis == -2) axis = indent
if (!flow && quote == "") {
if (matrix_indent >= 0 && indent <= matrix_indent) matrix_indent = -1
if (strategy_indent >= 0 && indent <= strategy_indent) strategy_indent = -1
}
q0 = quote
scan(lraw)
# The skip trusts no scan state for flows. A [ or { anywhere on the
# line (a value, a quoted string, an expression, a trailing comment)
# turns it off for the rest of the file, unless it is a flat one-line
# list, an empty {} or an expression's own double braces. Only those
# braces are dropped, so a [ or { inside an expression still counts.
# A flat list's items are plain (no quote, #, :, bracket, &, ! or *)
# or wholly quoted (no #, and no backslash in double quotes). noskip
# holds the first such line. A byte test, with no state for an input
# to corrupt.
t = lraw; gsub(/\$\{\{|\}\}/, "", t); gsub(/\{[[:space:]]*\}/, "", t)
gsub(/\[([^][{}"'#:,&!*]*|[[:space:]]*"[^"\\#]*"[[:space:]]*|[[:space:]]*'[^'#]*'[[:space:]]*)(,([^][{}"'#:,&!*]*|[[:space:]]*"[^"\\#]*"[[:space:]]*|[[:space:]]*'[^'#]*'[[:space:]]*))*\]/, "", t)
if (!noskip && t ~ /[[{]/) noskip = FNR
# A quote left open at the end of a line is refused, because the
# scan's quote state could then disagree with YAML's. It is reported
# where it opens; the kept state only holds the lines it spans, and
# changes no verdict, since this line already fails.
if (quote != "" && q0 == "") fail(FILENAME, FNR, "quoted scalar continues past this line — keep a quoted value on one line" (noskip ? "" : ", or, outside strategy:, write a long value as a >- block scalar"))
# A | or > value opens a block scalar. Under strategy: its text
# could hold an expression no rule would read, so it is refused
# while the skip is on; with the skip off, its text is read as YAML.
# !flow stays as a backstop: it keeps a header inside a flow the
# scan did see from opening a skip. block counts every "- ", "? "
# and ": " before the key, unlike indent, because the text has to
# be deeper than the key itself wherever the key sits.
if (!flow && quote == "" && substr(lraw, 1, cut - 1) ~ /^[[:space:]]*(-[[:space:]]+)*([^[:space:]][^#]*:[[:space:]]+)?([&!][^[:space:]]*[[:space:]]+)*[|>][-+0-9]*[[:space:]]*$/) {
match(lraw, /^[[:space:]]*([-?:][[:space:]]+)*/)
if (noskip) { if (!gated) gtext = RLENGTH }
else {
block = RLENGTH
if (strategy_indent >= 0) fail(FILENAME, FNR, "block scalar (| or >) under strategy: — write each matrix value inline, so the drift guard can read it")
}
}
}
# A line deeper than the runner key before it continues that value
# (a wrapped plain scalar), so the key line held only part of it.
value_indent >= 0 {
if (indent > value_indent) fail(FILENAME, FNR, "runner value continues on this line — write it on one line after the key")
value_indent = -1
}
# Job ids: a job named matrix or strategy opens no block.
/^jobs[[:space:]]*:[[:space:]]*$/ { in_jobs = 1; job_indent = -1; next }
in_jobs && indent == 0 { in_jobs = 0 }
in_jobs && job_indent < 0 { job_indent = indent }
{ job_line = in_jobs && indent == job_indent }
# Track the strategy and matrix blocks by indentation: a bare os: or
# runner: is validated only in a matrix, because the same key names a
# workflow_dispatch input or an action with: parameter, which select
# no runner. Each tracker arms on a bare key line, and re-arms only on
# one at or left of its key, so a matrix axis named matrix does not
# move it, and a key armed by a misread line gives way to the real
# one. Anything else on the key line (an expression, anchor,
# alias, tag or flow map) leaves the block unread, so the refusal
# rule below rejects the line instead.
!job_line && (strategy_indent < 0 || indent <= strategy_indent) && /^[[:space:]]*(-[[:space:]]*)?["']?strategy["']?[[:space:]]*:[[:space:]]*$/ { strategy_indent = armed = indent }
!job_line && (matrix_indent < 0 || indent <= matrix_indent) && /^[[:space:]]*(-[[:space:]]*)?["']?matrix["']?[[:space:]]*:[[:space:]]*$/ { matrix_indent = armed = indent; axis = -2 }
# Lines that may hide a runner label: a flow mapping carrying a
# runner/strategy/matrix/uses key; strategy: or matrix: with any
# inline value, include: with one inside a matrix; a YAML merge key
# or a bare alias item; inside a strategy block, any line holding an
# expression, whatever precedes it (a strategy, matrix, include or
# axis value set by one, inline or on the line below its key, behind
# a quote, flow list, anchor or tag), so an expression-valued axis is
# a deliberate edit here; a uses: line callee() cannot clear.
# Accepted false positives, all fail-closed: an env/with key named
# matrix or strategy with an inline value, or with no value and a
# sibling key on the next line; a runs-on list that repeats one
# hosted label; a flow map with an os, runner or uses key; any
# expression under a bare strategy: line,
# whatever key holds it (fail-fast, max-parallel, a non-runner axis,
# an include or exclude value); a trailing comment that holds a flow
# key or, under strategy:, an expression; a block scalar (| or >)
# under strategy:, even a multi-line non-runner value; a quoted
# scalar that continues past its line; | or > text read as YAML in a
# file where a line holds a [ or { that is not a flat one-line list,
# an empty {} or an expression's double braces (the noskip gate); a
# CR, NEL, LS or PS anywhere in a line (in block text, a quoted value
# or a comment), even where the break hides nothing; an os or
# runner list of two or more labels in an include or exclude item,
# or a nested list on an axis, whatever reads it; a step uses:
# value that is an alias or is written below its key.
lraw ~ /[{,][[:space:]]*["']?(runs-on|os|runner|strategy|matrix|include|uses)["']?[[:space:]]*:/ ||
/^[[:space:]]*(-[[:space:]]*)?["']?(strategy|matrix)["']?[[:space:]]*:[[:space:]]*[^[:space:]]/ ||
(/^[[:space:]]*(-[[:space:]]*)?["']?include["']?[[:space:]]*:[[:space:]]*[^[:space:]]/ && matrix_indent >= 0) ||
/^[[:space:]]*(-[[:space:]]*)?(<<[[:space:]]*:|\*[^*[:space:]])/ ||
(lraw ~ /\$\{\{/ && strategy_indent >= 0) ||
callee() {
fail(FILENAME, FNR, "line may hide a runner label from the drift guard — write `runs-on: <label>` / `os: [<labels>]` inline; keep `strategy:`, `matrix:`, `include:` as bare keys over a static block (no expression anywhere under strategy:, no anchors, aliases or flow maps); write each `uses:` value whole on its key's line, and call reusable workflows only from ./.github/workflows/ or $/.github/workflows/")
}
/^[[:space:]]*(-[[:space:]]*)?["']?runs-on["']?[[:space:]]*:/ ||
(/^[[:space:]]*(-[[:space:]]*)?["']?(os|runner)["']?[[:space:]]*:/ && matrix_indent >= 0) {
v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v)
if (v != "") value_indent = indent
if (v ~ /^["']?\$\{\{[[:space:]]*matrix\.os[[:space:]]*\}\}["']?$/) next
# An unclosed list or quote was wrapped, or cut by a # inside it.
if (v ~ /^\[/ && v !~ /\]$/ || gsub(/"/, "&", v) % 2 || gsub(/'/, "&", v) % 2) v = ""
r = v; nest = v ~ /^\[.*[[{]/
gsub(/[][ "']/, "", v)
# Only a plain comma list is split, so an expression or a nested
# list is quoted whole; the advice names the first label that is
# not allow-listed. A list of two or more labels that a job's
# runs-on receives whole fails even when HOSTED holds each one:
# on a runs-on line, or as one matrix value, which is any os or
# runner line off the axis column (an include or exclude item).
# An axis list is fine: each job gets one of its labels.
n = !nest && v ~ /^[a-z0-9._-]+(,[a-z0-9._-]+)*$/ ? split(v, items, ",") : 0
for (i = 1; i <= n && (items[i] in HOSTED); i++) ;
sel = n > 1 && (/^[[:space:]]*(-[[:space:]]*)?["']?runs-on/ || indent != axis)
if (!n || i <= n || sel) label(nest ? r : v, n ? items[i <= n ? i : 1] : "", sel)
}
END { exit bad }
AWK
cat > "$RUNNER_TEMP/runner-drift-guard-scan.sh" <<'SCAN'
# Runs the guard program ($1) over every workflow file at HEAD,
# dot-prefixed ones included, from the repository root. It reads the
# committed bytes, not the checkout: a .gitattributes
# working-tree-encoding or filter rewrites the checkout, and GitHub
# runs the committed file. Only regular files are read.
set -euo pipefail
case $1 in /*) prog=$1 ;; *) prog=$PWD/$1 ;; esac
tree=$(mktemp -d)
mkdir -p "$tree/.github/workflows"
git ls-tree -z HEAD .github/workflows/ > "$tree/ls"
files=()
while IFS= read -r -d '' entry; do
f=${entry#*$'\t'}
case $f in *.y*ml) ;; *) continue ;; esac
read -r mode _ oid <<< "${entry%%$'\t'*}"
if [ "$mode" != 100644 ] && [ "$mode" != 100755 ]; then
echo "::error file=$f::workflow file is not a regular file (git mode $mode) — the drift guard reads only regular files"
exit 1
fi
git cat-file blob "$oid" > "$tree/$f"
files+=("$f")
done < "$tree/ls"
[ "${#files[@]}" -gt 0 ] || { echo "::error::no workflow file at HEAD"; exit 1; }
cd "$tree"
awk -f "$prog" "${files[@]}"
SCAN
# One awk pass over the committed workflows, under set -euo pipefail: a git
# or program error exits non-zero and fails the step instead of reading as
# "no match".
- name: Every runner value is an allow-listed GitHub-hosted label
run: bash "$RUNNER_TEMP/runner-drift-guard-scan.sh" "$RUNNER_TEMP/runner-drift-guard.awk"
# After the scan, not before: the script lives under .github/scripts/,
# which a `contents: write` job can rewrite, so it must not run ahead of
# the scan with the programs' paths in hand. It still fails the job when
# either program gets any fixture wrong, and it runs when the scan fails
# too, so a program edit that breaks the real tree still shows which
# fixture broke; the scan's result is already recorded by then.
- name: Guard self-test (regression fixtures)
if: ${{ !cancelled() }}
run: bash .github/scripts/runner-drift-guard-selftest.sh "$RUNNER_TEMP/runner-drift-guard.awk" "$RUNNER_TEMP/runner-drift-guard-scan.sh"
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs:
[lint, test, test-integration, coverage, security, smoke-test, workers, runner-drift-guard]
if: always()
steps:
- name: Check all jobs succeeded
run: |
if [[ "${{ needs.lint.result }}" != "success" ]] || \
[[ "${{ needs.test.result }}" != "success" ]] || \
[[ "${{ needs.test-integration.result }}" != "success" ]] || \
[[ "${{ needs.coverage.result }}" != "success" ]] || \
[[ "${{ needs.security.result }}" != "success" ]] || \
[[ "${{ needs.smoke-test.result }}" != "success" ]] || \
[[ "${{ needs.workers.result }}" != "success" ]] || \
[[ "${{ needs.runner-drift-guard.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
echo "All jobs succeeded"