Repository navigation
536 lines (507 loc) · 27.5 KB
/
Copy pathtest.yml
File metadata and controls
536 lines (507 loc) · 27.5 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
name: tests
# Runs the unit / multi-node / smoke suite + the README-badge
# updater. Triggers on any change that could plausibly affect a
# test result: source code, ANY test subtree (unit, multi-node,
# smoke, AND integration — tests that share helpers in
# `tests/integration/` could shift unit-test imports), dependency
# manifest, tsconfig — and the prose the suite reads.
#
# That last clause is not decoration. This file used to say docs-only,
# README-only, ROADMAP and CHANGELOG changes "can't change test outcomes",
# and five test files now read exactly those: SupplyChainDocs (CHANGELOG +
# two pages), GossipReplayBoundDocumented (ROADMAP + two pages),
# RateLimitEvictionClaims, ExampleWiringClaims and SleepRatchet. A
# ROADMAP-only edit turning one of those claims back was measured red while
# CI would not have run at all. A guard CI skips on the file it guards is
# a guard only for whoever runs the suite locally.
on:
# Run on every branch push (feature work merges locally, so a branch push
# is often the only pre-merge signal), not just `develop`.
push:
branches: ['**']
paths:
- 'src/**'
- 'tests/**'
- 'package.json'
- '.bun-version'
- 'bun.lock'
- 'tsconfig.json'
- 'tsconfig.dev.json'
- 'docs/**'
- 'ROADMAP.md'
- 'CHANGELOG.md'
- 'README.md'
- '.github/workflows/test.yml'
pull_request:
branches: [main, develop]
paths:
- 'src/**'
- 'tests/**'
- 'package.json'
- '.bun-version'
- 'bun.lock'
- 'tsconfig.json'
- 'tsconfig.dev.json'
- 'docs/**'
- 'ROADMAP.md'
- 'CHANGELOG.md'
- 'README.md'
- '.github/workflows/test.yml'
workflow_dispatch:
# Serialise runs that share the same ref — without this, two develop-
# push runs that both reach the "Commit + push README" step race on
# `git push`: the second fetches a base that's already moved and
# fails with `non-fast-forward`. Grouping by `github.ref`:
# - develop pushes (`refs/heads/develop`) queue behind each other →
# README updates land in order, no force-push gymnastics.
# - PRs (`refs/pull/N/merge`) get their own group per PR →
# different PRs don't block each other.
# - Re-pushes to the same PR cancel the in-flight run → no
# wasted CI on stale commits.
# `cancel-in-progress` evaluated per event type: cancel for PRs
# (drop stale work), queue for develop pushes (preserve every commit's
# README state).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
# Typecheck, run the suite ONCE with `--coverage`, and parse the stats
# out of that single run. The suite used to run twice (a `test` job
# without coverage, then `update-readme` with coverage), which gave the
# parser a different bun output to read than the one the build status
# came from — a flaky timing-sensitive test under coverage
# instrumentation could push the badge to "1489 of 1490" while the
# build itself stayed green. One run, one source of truth; the `badge`
# job below consumes this job's numbers and never re-runs anything.
runs-on: ubuntu-latest
timeout-minutes: 40
# Read-only, and the checkout below drops its credential too (#622).
# This job installs and executes the entire devDependency tree — the
# last place a token that can write to the repository belongs. The
# README push it used to do lives in `badge`, which runs no third-party
# code at all.
permissions:
contents: read
# Consumed by `badge`. Both producers publish even when the suite fails,
# which is exactly when the badge has to flip to red — `stats` parses the
# counts out of the JUnit report, and `coverage` (which therefore runs on
# a red suite too) hands back the figure it gated on.
outputs:
pass: ${{ steps.stats.outputs.pass }}
total: ${{ steps.stats.outputs.total }}
lines: ${{ steps.coverage.outputs.lines }}
steps:
# The run's artifacts, defined once because two steps address them — one
# writes them, the next gates on them — and a path spelled twice is a
# path that can be edited once. All under the runner temp dir so the
# workspace the badge job checks out stays clean.
#
# This is a step rather than a job-level `env:` block because the
# `runner` context is not available there — GitHub rejects the whole
# file at validation time, so the workflow does not run at all.
# `$RUNNER_TEMP` is the same directory, reachable from any step's shell.
- name: Resolve the run's artifact paths
run: |
{
echo "JUNIT_FILE=$RUNNER_TEMP/test-results.junit.xml"
echo "LOG_FILE=$RUNNER_TEMP/bun-test.log"
# The lcov half of the same run. The per-module floors cannot come
# from the text table even in principle: a rollup of bun's per-file
# percentages averages a ten-line barrel against a thousand-line
# coordinator, where lcov's LF:/LH: counts weight by the lines that
# exist.
echo "COVERAGE_DIR=$RUNNER_TEMP/coverage"
} >> "$GITHUB_ENV"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Nothing in this job talks to the remote: no test, source file or
# script shells out to git, and `bun.lock` has no git-protocol
# dependencies. So the pushable credential checkout leaves behind
# in `.git/config` is pure attack surface for the ~2000 packages
# installed one step later.
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
# Frozen, like build.yml and publish.yml: an unfrozen install resolves
# whatever `package.json`'s ranges allow right now, so the required
# `tests` check could pass against a dependency set no lockfile ever
# recorded and nobody could reproduce. Dependabot does not speak the
# Bun lockfile format, so its PRs fail here until `bun.lock` is
# regenerated — that is the intended signal, and #817 is what removes
# the manual step.
- name: Install
run: bun install --frozen-lockfile
# The DevTools UI has its own install, deliberately not a workspace, and
# the root typecheck reaches it through scripts/ui-toolchain.mjs. That
# guard skips a missing toolchain locally and fails hard under CI (#483),
# so the UI is type-checked on every run rather than only by whoever
# happens to have installed it.
#
# Frozen, like the root install above, and written out rather than
# `bun run ui:install`: that script is the developer's command — the
# nested counterpart of a bare `bun install`, which is how
# devtools-ui/bun.lock gets regenerated after a manifest edit — and
# through it this was the one CI install that resolved its ranges afresh
# (#1622). A `bun run` here is also read through to the script it runs
# by tests/unit/ci/WorkflowHygiene.test.ts now, so the flag is required
# either way.
- name: Install the DevTools UI toolchain
run: bun install --frozen-lockfile --cwd devtools-ui
- name: Typecheck
run: bun run typecheck
# The UI's own suite. It is a separate runner (Vitest, in jsdom) because
# it needs a DOM, which `bun test` deliberately does not provide — the
# framework-free half of the UI stays on `bun test` and runs above.
- name: DevTools UI specs
run: bun run test:ui
- name: Tests with coverage
id: stats
# Every suite runs here. Three used to be removed by
# `ACTOR_TS_SKIP_FLAKY_MNS=1` (#538) and are back: the two
# worker-thread ones on 21 consecutive green nights of
# `nightly-flakes.yml` against a written criterion of fourteen, and
# `LeaseMajority` because its cause was found and fixed (#839) rather
# than waited out.
run: |
# bun's output goes to a FILE, never through a pipe. Under GitHub
# Actions bun emits one annotation line per test — ~8700 lines for
# this suite — and `OUTPUT=$(bun test …)` pushed that whole burst
# through a command-substitution pipe. bun died mid-flush with
# `error: An internal error occurred (WriteFailed)`, truncating the
# coverage table three-quarters of the way in and taking the JUnit
# report with it (oven-sh/bun#15860 is the same family: large
# suites, intermittent, "the second attempt ran fine").
#
# The identical command reproduces clean locally, where bun prints
# no per-test lines and the burst is eight times smaller — so the
# volume through the pipe is the trigger, not the flags. A regular
# file cannot short-write the way a pipe can.
#
# Failure is not allowed to kill the step: the parsed numbers have
# to reach `steps.stats.outputs` so the README can still flip to a
# red badge when the suite breaks. bun's exit status is replayed
# at the end so the job's own result is unchanged.
# Randomized test order (#1422). Four suites rested on declaration
# order and passed every run in it: a fixture block that wrote a value
# in one test and read it in the next, two files a process-wide module
# mock moved onto the wrong code path, and one that counted microtask
# turns behind a dangling operation. A shuffle is what found them.
#
# The seed is drawn HERE rather than left to bun, because a seed
# nobody can read is not a reproduction: bun reports it in the
# trailing summary block, and under GitHub Actions it stops printing
# that block and reports the totals out of band. That is the same
# trap the JUnit parse below exists to avoid.
SEED=$(( (RANDOM << 15) + RANDOM ))
echo "test order seed: --seed=$SEED"
printf 'Test order seed: `--seed=%s`\n' "$SEED" >> "$GITHUB_STEP_SUMMARY"
# `--parallel` is on here, and it took a Bun release to get there
# (#1521; #1332 is why it was off). On bun 1.4.0 the flag changed
# what was *measured* rather than what was tested: 407 files reported
# a larger instrumented-line denominator and none a smaller one, so
# the aggregate read 79.16 % against 93.82 % on identical execution —
# straight through the 90 % floor.
#
# Bun 1.4.1 fixed the under-reporting across workers, and the 1.4.2
# pin measures clean: over 728 lcov records, zero report a larger
# denominator, 20 report one smaller by a line or three, and the
# numerator is identical at 56 041 lines. The aggregate agrees to
# 0.05 points (94.66 % against 94.61 %) and the run goes 352 s to 41 s.
#
# Flag order is load-bearing: `tests/unit/ci/CoverageGate.test.ts`
# finds this invocation with `startsWith("bun test --coverage")`, so
# `--parallel` goes after it. `WorkflowHygiene.test.ts` now asserts
# the flag is present, and goes red if it is dropped silently.
set +e
bun test --coverage --parallel --seed="$SEED" \
--coverage-reporter=text --coverage-reporter=lcov \
--coverage-dir="$COVERAGE_DIR" \
--reporter=junit --reporter-outfile="$JUNIT_FILE" > "$LOG_FILE" 2>&1
STATUS=$?
set -e
# Replay bun's output so the GitHub Actions UI still shows
# per-test results just like a regular `bun test` step.
cat "$LOG_FILE"
echo "bun test exited with status $STATUS"
# Test counts come from the JUnit report, never from the console.
# Bun 1.3.14 stopped printing the trailing
# N pass / M skip / K fail / Ran X tests across Y files
# summary block under GitHub Actions (it groups its output there and
# reports the totals out of band), so the previous
# `grep "^[[:space:]]+[0-9]+ pass$"` scrape matched nothing. That
# alone was survivable; what made it ship was the `[[ -z "$PASS" ]]
# && PASS=0` fallback, which turned "the parser found nothing" into
# a perfectly plausible zero and pushed a `tests-0 of 0` badge from
# a green run. Hence both halves of this rewrite: read a contract
# that does not move between bun releases, and treat an unreadable
# count as an error rather than as zero.
#
# <testsuites name="bun test" tests="6269" failures="0" skipped="23" …>
#
# `tests` counts skipped tests too, so subtract them. `total` stays
# "actually-executed tests" exactly as before — "6246 of 6246" means
# all 6246 active tests passed, with skips counted separately.
PASS=''
TOTAL=''
if [[ -s "$JUNIT_FILE" ]]; then
# The root element only — nested <testsuite> rows carry the same
# attribute names per file.
SUMMARY=$(grep -m1 -o '<testsuites [^>]*>' "$JUNIT_FILE" || true)
echo "----- JUnit summary (parser input) -----"
echo "${SUMMARY:-<none found>}"
echo "----------------------------------------"
TESTS=$(printf '%s' "$SUMMARY" | grep -oE ' tests="[0-9]+"' | grep -oE '[0-9]+' || true)
FAILURES=$(printf '%s' "$SUMMARY" | grep -oE ' failures="[0-9]+"' | grep -oE '[0-9]+' || true)
SKIPPED=$(printf '%s' "$SUMMARY" | grep -oE ' skipped="[0-9]+"' | grep -oE '[0-9]+' || true)
if [[ -n "$TESTS" && -n "$FAILURES" && -n "$SKIPPED" ]]; then
TOTAL=$((TESTS - SKIPPED))
PASS=$((TOTAL - FAILURES))
fi
else
echo "::error::bun wrote no JUnit report to $JUNIT_FILE — the run died before the reporter flushed, or --reporter=junit is no longer supported."
fi
# A suite that executes nothing is a broken parser, not a green run.
if [[ -z "$TOTAL" || "$TOTAL" -le 0 || "$PASS" -lt 0 ]]; then
echo "::error::Could not read test counts from the JUnit report — refusing to report a zero. The README keeps its previous figures."
PASS=''
TOTAL=''
fi
# Coverage is NOT parsed here. It used to be — `grep "^All files" |
# awk -F'|' '{print $3}'`, truncated with `${LINES%.*}` and compared
# as an integer in the gate step below — while a second
# implementation of the same parse lived in the gate script for local
# runs. Two parsers over one table agreed only for as long as the
# floor was a whole number, and only one of them could ever gate the
# per-module floors, which need the lcov half of the run. The script
# is the only parser now and this workflow is its caller; the badge
# figure comes back out of it. #541, #1016.
# Only ever publish numbers that parsed. An absent output trips the
# `badge` job's guard, which leaves the last good README in place —
# far better than overwriting it with a confident-looking zero.
if [[ -n "$PASS" ]]; then
echo "pass=$PASS" >> "$GITHUB_OUTPUT"
echo "total=$TOTAL" >> "$GITHUB_OUTPUT"
fi
echo "Parsed: ${PASS:-?} of ${TOTAL:-?} tests passing"
# The test counts only render a badge. `main` is branch-protected
# on this check, so a cosmetic parse failure must never be able to
# block a release: the `badge` job annotates a warning and leaves
# the README on its previous figures, which is exactly what
# publishing nothing rather than a zero is for. Coverage is the
# other kind — it gates — and the step below is what fails on it.
# Propagate the original bun exit status — non-zero turns
# the job red, but the step outputs above are already
# written so the README job downstream still has data.
exit $STATUS
- name: Coverage-floor gate
id: coverage
# `scripts/coverage-gate.mjs` over the two artifacts of the run that
# already happened — the captured log for the aggregate floor, the lcov
# report for the per-module ones. It refuses `--log` without `--lcov`,
# so this step cannot degrade into gating half of what it says it gates.
# The floors themselves live in the script and nowhere else, on purpose:
# a COVERAGE_LINE_FLOOR here would be a second place the number lives
# and a way to loosen the gate without the loosening showing up in a
# diff of the gate. `tests/unit/ci/CoverageGate.test.ts` asserts that.
#
# `!cancelled()` rather than `success()`: the badge figure comes out of
# this step now, and a red suite is exactly when the README has to stop
# claiming the old number. Skipped when the suite never ran at all (a
# typecheck failure, a frozen-install rejection) — there is no log to
# read then, and an error about a missing file would bury the real one.
if: ${{ !cancelled() && steps.stats.conclusion != 'skipped' }}
run: bun scripts/coverage-gate.mjs --log="$LOG_FILE" --lcov="$COVERAGE_DIR/lcov.info"
# The badge updater — the only job in this workflow that can write to the
# repository, and deliberately the only one that runs no third-party code
# (#622). It installs nothing, builds nothing and re-runs nothing: it
# consumes `test`'s parsed numbers, rewrites README.md with sed, and
# pushes. Before the split, `contents: write` plus a persisted git
# credential sat in the same job that executed the whole devDependency
# tree, so any postinstall script or compromised transitive package could
# have pushed to develop.
#
# The workflow-level `concurrency` group covers this job too, so two
# develop pushes still queue instead of racing on `git push`.
# A flaky test is cheapest to fix in the change that introduces it, and that is
# the one moment nothing here looked for one. The suite above runs every test
# once, so a test that fails one run in five passes it four times out of five
# and enters the tree; `nightly-flakes.yml` then finds it days later,
# attributed to nobody. Repetition is what separates the two cases, and it
# only has to be paid on what changed.
#
# The two questions are different, and this is the second one. A shuffled run
# asks whether a test depends on running *after* another one — the suite above
# now asks that. Repetition asks whether it depends on the run it was written
# in, and nothing asked that per branch.
#
# **Scoped to changed test files rather than to what `--changed` selects, and
# the numbers are why.** `--changed` follows imports transitively: measured on
# this tree, one edit to `src/persistence/object-storage/Compression.ts`
# selected 346 files and 5 505 tests in 209 s, and a base whose diff reached a
# `tests/util/` helper selected 468 files and 8 777 tests in 252 s. Five
# reruns of either is a half-hour job, and what it would add over the suite
# above is repetition — which pays off where the code is new. So the probe
# follows the new code, and a `src/` or helper change stays covered once by
# the suite above, exactly as before.
#
# A branch that adds no test therefore gets no probe. That is the honest
# scope: what is being looked for is a flaky test a branch adds.
changed-probe:
name: changed tests, five times over
runs-on: ubuntu-latest
timeout-minutes: 20
if: github.event_name == 'pull_request'
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The merge base has to exist locally for `git diff` to reach it; a
# depth-1 checkout has neither it nor the base commit.
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: .bun-version
- run: bun install --frozen-lockfile
- name: Re-run the changed tests five times, shuffled
env:
# Through the environment rather than interpolated into the script: on
# a fork pull request this is attacker-controlled text.
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
SUITES=$(git diff --name-only "$BASE_SHA"...HEAD -- 'tests/**/*.test.ts')
if [ -z "$SUITES" ]; then
echo "no test file changed on this branch - nothing to probe."
exit 0
fi
echo "changed suites:"
printf '%s\n' "$SUITES"
# A deleted or renamed-away suite is still named by the diff, and bun
# fails on a path that is not there.
PRESENT=''
for suite in $SUITES; do
if [ -f "$suite" ]; then PRESENT="$PRESENT $suite"; fi
done
if [ -z "$PRESENT" ]; then
echo "every changed suite was deleted - nothing to probe."
exit 0
fi
SEED=$(( (RANDOM << 15) + RANDOM ))
echo "test order seed: --seed=$SEED"
printf 'Probe seed: `--seed=%s`\n' "$SEED" >> "$GITHUB_STEP_SUMMARY"
# Unquoted on purpose: the list is one path per argument and has to
# split. Paths in this tree carry no spaces, and one that did would be
# a finding of its own.
# shellcheck disable=SC2086
bun test --rerun-each=5 --seed="$SEED" $PRESENT
badge:
name: README badges
needs: test
# `!cancelled()`, not `success()` — a red suite is exactly when the
# badge has to flip to red. PRs and every branch but develop skip
# (forks have no push rights anyway).
if: |
!cancelled() &&
github.event_name == 'push' &&
github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
# "The suite failed" and "the suite never ran" used to look identical
# from here: both left the outputs empty, both skipped the badge
# silently, and the README kept showing the last good numbers. A
# frozen install that rejects a drifted lockfile is exactly the second
# case, so say so out loud instead of leaving stale figures on the
# front page unexplained.
#
# The plausibility check below is the second line of defence, added
# after a bun upgrade broke the upstream parser and this job cheerfully
# published `tests-0 of 0` from a green run: the guard only asked
# whether the numbers were *present*, and a zero is present. Upstream
# no longer emits zeros, but the README is the project's front page —
# it is worth two jobs disagreeing before it gets rewritten.
- name: Check the suite produced numbers
id: parsed
env:
PASS: ${{ needs.test.outputs.pass }}
TOTAL: ${{ needs.test.outputs.total }}
LINES: ${{ needs.test.outputs.lines }}
run: |
if [[ -z "$PASS" || -z "$TOTAL" || -z "$LINES" ]]; then
echo "::warning title=README badges not updated::The test job produced no numbers — it failed before or during the parse step (a frozen-install rejection, a typecheck error, a runner crash). The README still shows the previous run's figures."
echo "updatable=false" >> "$GITHUB_OUTPUT"
elif ! [[ "$PASS" =~ ^[0-9]+$ && "$TOTAL" =~ ^[0-9]+$ && "$LINES" =~ ^[0-9]+$ ]] || (( TOTAL == 0 )); then
echo "::warning title=README badges not updated::The test job reported an implausible result ($PASS of $TOTAL tests, $LINES% coverage). A zero or non-numeric count means the parser broke, not that the suite is empty — the README still shows the previous run's figures."
echo "updatable=false" >> "$GITHUB_OUTPUT"
else
echo "updatable=true" >> "$GITHUB_OUTPUT"
echo "the suite reported $PASS of $TOTAL passing at $LINES% line coverage"
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: steps.parsed.outputs.updatable == 'true'
- name: Update README badges + inline references
if: steps.parsed.outputs.updatable == 'true'
# Through `env:` rather than `${{ }}` inside the script: a workflow
# expression is pasted into the shell before it runs, so a parser
# that ever yielded something other than digits would be executing
# it. Environment variables are values, never code.
env:
PASS: ${{ needs.test.outputs.pass }}
TOTAL: ${{ needs.test.outputs.total }}
LINES: ${{ needs.test.outputs.lines }}
run: |
# Pick badge color from pass/total ratio:
# pass == total → green (22c55e) — all green
# pass < total → red (ef4444) — at least one fail
# Coverage badge follows the same green by default; thresholds
# could be added later (e.g. <70% → red, <85% → yellow).
if [[ "$PASS" -eq "$TOTAL" ]]; then
TEST_COLOR="22c55e"
STATUS_LABEL="green"
else
TEST_COLOR="ef4444"
STATUS_LABEL="red"
fi
COV_COLOR="22c55e"
echo "Computed badge state:"
echo " tests-${PASS}/${TOTAL} (${STATUS_LABEL})"
echo " coverage-~${LINES}%"
# 1. Static badge URLs (img.shields.io) — pattern matches the
# full 'tests-X%20of%20Y-COLOR' segment so the colour gets
# rewritten when the ratio shifts.
sed -i -E "s|tests-[0-9]+%20of%20[0-9]+-[0-9a-fA-F]{6}|tests-${PASS}%20of%20${TOTAL}-${TEST_COLOR}|g" README.md
sed -i -E "s|coverage-~[0-9]+%25-[0-9a-fA-F]{6}|coverage-~${LINES}%25-${COV_COLOR}|g" README.md
# 2. Inline prose references. Test-count regex demands 3+ digits
# so '~9 tests' (env-gated subset elsewhere) is not matched.
sed -i -E "s|~[0-9]{3,}( tests)|~${PASS}\1|g" README.md
sed -i -E "s|~[0-9]+( % line)|~${LINES}\1|g" README.md
# Make the result of the substitutions explicit in the log.
# Previously the only signal that this step had done anything
# was a downstream "no badge changes" line — opaque when the
# README already happened to match the freshly-computed
# numbers (e.g. an unchanged-flake commit re-running on the
# same red badge state). Now the diff is visible inline.
if git diff --quiet README.md; then
echo "README already reflects ${PASS}/${TOTAL} ${STATUS_LABEL} — no edits needed"
else
echo "README diff after substitution:"
git --no-pager diff -U0 README.md
fi
- name: Commit + push README if changed
if: steps.parsed.outputs.updatable == 'true'
run: |
if [[ -n "$(git status --porcelain README.md)" ]]; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add README.md
git commit -m "chore(readme): update test count + coverage stats [skip ci]"
git push
echo "pushed README update"
else
echo "README unchanged — nothing to commit (the previous run already pushed these numbers)"
fi