Skip to content

Commit 580b4b0

Browse files
aledbfclaude
andcommitted
ci(parity): shard the daily runtime matrix and gate it on daily changes
The daily parity-runtime-full job ran the whole 175-case matrix on one runner at -parallel 3. Under that contention the `up` setups timed out / were starved, so every command that reuses the container (exec, run-user-commands, set-up) failed downstream with "Dev container not found" / exit 127. Two consecutive scheduled runs failed with the same ~13-case set (the 14th rotating) — a load flake, not a code regression. Shard the full matrix across 4 independent runners reusing the existing PARITY_SHARD_INDEX/TOTAL harness (the same one the push/PR lane uses; the round-robin split is a proven exact cover), each at -parallel 2 to match the push/PR shard lane's safe load. Extract the shared toolchain + runner prep + reference setup into a local composite action (setup-parity-runtime) so the two runtime jobs stay identical. Publish parity moves to its own job, and a new report job aggregates the shards + publish into one gate and merges every lane's covdata into the true cross-lane number. Gate the expensive daily lanes on daily-changes: a scheduled run does the heavy work only when HEAD has a commit within the last day (scripts/daily-changed.sh); manual dispatch always runs. On a private/billed repo this skips the matrix on quiet days. Regression tests: parity_sharding_workflow_test.go asserts every sharded job's PARITY_SHARD_TOTAL equals its matrix length (a mismatch would silently drop cases behind a green run); daily_changed_script_test.go covers the run/skip boundary of daily-changed.sh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5ad6a59 commit 580b4b0

5 files changed

Lines changed: 366 additions & 46 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Set up parity runtime
2+
description: >-
3+
Shared setup for the Docker-backed parity jobs: Go/Node/Task toolchain, the CI
4+
runner prep (free disk + containerd image store), and the compiled TypeScript
5+
reference oracle. Used by both the scoped push/PR shard job and the daily full
6+
shard job so their environments stay byte-for-byte identical.
7+
8+
inputs:
9+
repo-token:
10+
description: Token passed to setup-task for GitHub API calls (avoids rate limits).
11+
required: false
12+
default: ""
13+
14+
runs:
15+
using: composite
16+
steps:
17+
- uses: actions/setup-go@v6
18+
with:
19+
go-version-file: go.mod
20+
- uses: actions/setup-node@v6
21+
with:
22+
node-version: "20"
23+
- uses: go-task/setup-task@v2
24+
with:
25+
version: 3.x
26+
repo-token: ${{ inputs.repo-token }}
27+
# Free disk + enable the containerd image store (build-cache export). Reuses
28+
# the Taskfile so the runner prep is identical to release.yml.
29+
- run: task ci:prepare-runner
30+
shell: bash
31+
- run: task reference
32+
shell: bash

.github/workflows/go-cli.yml

Lines changed: 135 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -172,26 +172,11 @@ jobs:
172172
base="${{ github.event.before }}"; head="${{ github.sha }}"
173173
fi
174174
bash scripts/parity-affected-commands.sh "$base" "$head" >> "$GITHUB_OUTPUT"
175-
- uses: actions/setup-go@v6
176-
if: steps.affected.outputs.run == 'true'
177-
with:
178-
go-version-file: go.mod
179-
- uses: actions/setup-node@v6
180-
if: steps.affected.outputs.run == 'true'
181-
with:
182-
node-version: "20"
183-
- uses: go-task/setup-task@v2
184-
if: steps.affected.outputs.run == 'true'
175+
# Toolchain + runner prep + reference oracle, shared with parity-runtime-full.
176+
- if: steps.affected.outputs.run == 'true'
177+
uses: ./.github/actions/setup-parity-runtime
185178
with:
186-
version: 3.x
187179
repo-token: ${{ secrets.GITHUB_TOKEN }}
188-
# Free disk + enable the containerd image store (build-cache export). Reuses
189-
# the Taskfile so the runner prep is identical to release.yml.
190-
- if: steps.affected.outputs.run == 'true'
191-
run: task ci:prepare-runner
192-
193-
- if: steps.affected.outputs.run == 'true'
194-
run: task reference
195180
- if: steps.affected.outputs.run == 'true'
196181
env:
197182
PARITY_COMMAND: ${{ steps.affected.outputs.commands }}
@@ -225,57 +210,161 @@ jobs:
225210
*) echo "one or more runtime shards failed"; exit 1 ;;
226211
esac
227212
228-
# Full runtime parity matrix + publish parity. Runs once a day (schedule) and
229-
# on manual dispatch — the exhaustive backstop for the scoped push/PR job above.
230-
parity-runtime-full:
213+
# Gate the expensive daily lanes on "did the repo change today?". A manual dispatch
214+
# always runs; a scheduled run only proceeds if HEAD has a commit within the last
215+
# day (scripts/daily-changed.sh). On a private/billed repo this skips the full
216+
# runtime matrix + publish on quiet days. Runs on schedule/dispatch so it is never
217+
# skipped for those events — the heavy jobs `needs:` it, and a skipped need would
218+
# skip them regardless of its output.
219+
daily-changes:
231220
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
232221
runs-on: ubuntu-latest
233-
needs: lint-and-test
222+
outputs:
223+
run: ${{ steps.check.outputs.run }}
224+
steps:
225+
- uses: actions/checkout@v7
226+
- id: check
227+
run: |
228+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
229+
echo "manual dispatch → always run" >&2
230+
echo "run=true" >> "$GITHUB_OUTPUT"
231+
else
232+
bash scripts/daily-changed.sh >> "$GITHUB_OUTPUT"
233+
fi
234+
235+
# Full runtime parity matrix. Runs once a day (schedule, only when the repo changed
236+
# — see daily-changes) and on manual dispatch — the exhaustive backstop for the
237+
# scoped push/PR job above. Sharded across independent runners (same
238+
# PARITY_SHARD_INDEX/TOTAL harness as the push/PR lane) so the 175-case matrix no
239+
# longer contends for one runner's CPU/disk/daemon, which was starving the
240+
# container setups and failing downstream exec cases. PARITY_SHARD_TOTAL MUST equal
241+
# the length of matrix.shard — parity_sharding_workflow_test.go enforces that, so a
242+
# mismatch can't silently drop cases.
243+
parity-runtime-full:
244+
needs: [lint-and-test, daily-changes]
245+
if: needs.daily-changes.outputs.run == 'true'
246+
runs-on: ubuntu-latest
247+
strategy:
248+
# fail-fast off so one shard's failure still reports the others.
249+
fail-fast: false
250+
matrix:
251+
shard: [0, 1, 2, 3]
234252
env:
235253
PARITY_RUNTIME_TIMEOUT: "10m"
236-
# Exhaustive daily run on a dedicated (non-gating) job: raise parallelism to
237-
# 3 now that per-case isolation makes concurrent builds safe. Not sharded
238-
# here because this lane also measures coverage (single covdata merge).
239-
PARITY_PARALLEL: "3"
254+
# Each shard owns ~a quarter of the matrix, so -parallel 2 per runner matches
255+
# the push/PR shard lane's proven-safe load instead of the old single-runner 3.
256+
PARITY_PARALLEL: "2"
257+
PARITY_SHARD_INDEX: ${{ matrix.shard }}
258+
PARITY_SHARD_TOTAL: "4"
240259
steps:
241260
- uses: actions/checkout@v7
242261
with:
243262
submodules: recursive
244-
- uses: actions/setup-go@v6
245-
with:
246-
go-version-file: go.mod
247-
- uses: actions/setup-node@v6
248-
with:
249-
node-version: "20"
250-
- uses: go-task/setup-task@v2
263+
- uses: ./.github/actions/setup-parity-runtime
251264
with:
252-
version: 3.x
253265
repo-token: ${{ secrets.GITHUB_TOKEN }}
254-
- run: task ci:prepare-runner
255-
256-
- run: task reference
257-
# The full lanes run the instrumented binary, so this measures coverage AND
258-
# gates parity; coverage:merge unions them into one cross-lane report.
266+
# Instrumented binary: measures coverage AND gates parity on this shard's slice.
259267
- run: task coverage:parity-runtime
260-
- run: task coverage:parity-publish
261-
- name: Merged coverage report
268+
- name: Coverage summary
262269
if: always()
263-
run: task coverage:merge | tee -a "$GITHUB_STEP_SUMMARY"
270+
run: bash scripts/coverage-report.sh artifacts/coverage/data/runtime "parity:runtime shard ${{ matrix.shard }}" >> "$GITHUB_STEP_SUMMARY"
264271
- if: always()
265272
run: |
266273
mkdir -p artifacts
267274
git -C reference rev-parse HEAD > artifacts/reference-commit.txt
268275
- uses: actions/upload-artifact@v7
269276
if: always()
270277
with:
271-
name: parity-runtime-full-v0.88.0
272-
path: |
273-
artifacts/
274-
artifacts/coverage/
278+
name: parity-runtime-full-v0.88.0-shard-${{ matrix.shard }}
279+
path: artifacts/
280+
if-no-files-found: error
281+
# covdata slice for the cross-lane merge (distinct name per shard so the merge
282+
# job unions them instead of clobbering).
283+
- uses: actions/upload-artifact@v7
284+
if: always()
285+
with:
286+
name: covdata-runtime-shard-${{ matrix.shard }}
287+
path: artifacts/coverage/data/runtime
275288
if-no-files-found: error
276289
- if: always()
277290
run: task clean
278291

292+
# Publish parity (features/templates → ephemeral OCI registry). Independent of the
293+
# runtime matrix, so it runs as its own job rather than stealing a runtime shard's
294+
# runner. Was folded into the old monolithic parity-runtime-full.
295+
parity-publish-full:
296+
needs: [lint-and-test, daily-changes]
297+
if: needs.daily-changes.outputs.run == 'true'
298+
runs-on: ubuntu-latest
299+
steps:
300+
- uses: actions/checkout@v7
301+
with:
302+
submodules: recursive
303+
- uses: ./.github/actions/setup-parity-runtime
304+
with:
305+
repo-token: ${{ secrets.GITHUB_TOKEN }}
306+
- run: task coverage:parity-publish
307+
- name: Coverage summary
308+
if: always()
309+
run: bash scripts/coverage-report.sh artifacts/coverage/data/publish "parity:publish" >> "$GITHUB_STEP_SUMMARY"
310+
- uses: actions/upload-artifact@v7
311+
if: always()
312+
with:
313+
name: covdata-publish
314+
path: artifacts/coverage/data/publish
315+
if-no-files-found: error
316+
- if: always()
317+
run: task clean
318+
319+
# Daily gate + cross-lane coverage. Aggregates the sharded runtime lane and the
320+
# publish lane into one pass/fail (mirrors the push/PR parity-runtime gate) and
321+
# merges every lane's covdata — unit + e2e + contract (which also run on schedule)
322+
# plus the runtime shards + publish — into the one true cross-lane number.
323+
parity-runtime-full-report:
324+
if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
325+
runs-on: ubuntu-latest
326+
needs: [lint-and-test, e2e, parity, parity-runtime-full, parity-publish-full]
327+
steps:
328+
- uses: actions/checkout@v7
329+
- uses: actions/setup-go@v6
330+
with:
331+
go-version-file: go.mod
332+
- uses: go-task/setup-task@v2
333+
with:
334+
version: 3.x
335+
repo-token: ${{ secrets.GITHUB_TOKEN }}
336+
- name: Download all lane covdata
337+
uses: actions/download-artifact@v8
338+
with:
339+
pattern: covdata-*
340+
path: artifacts/coverage/data
341+
# download-artifact nests each artifact under covdata-<lane>/; flatten to <lane>/
342+
# so coverage:merge sees one dir per lane (the runtime shards stay distinct).
343+
- name: Normalize lane dirs
344+
run: |
345+
cd artifacts/coverage/data
346+
for d in covdata-*; do [ -d "$d" ] && mv "$d" "${d#covdata-}"; done
347+
ls -R . || true
348+
- name: Merged coverage report
349+
run: task coverage:merge | tee -a "$GITHUB_STEP_SUMMARY"
350+
- uses: actions/upload-artifact@v7
351+
with:
352+
name: covdata-merged-full
353+
path: artifacts/coverage/data/merged.out
354+
if-no-files-found: warn
355+
# Final verdict last, so the coverage report is posted even when a shard failed.
356+
- name: Gate on all runtime shards + publish
357+
run: |
358+
runtime="${{ needs.parity-runtime-full.result }}"
359+
publish="${{ needs.parity-publish-full.result }}"
360+
echo "runtime shards: $runtime, publish: $publish"
361+
for r in "$runtime" "$publish"; do
362+
case "$r" in
363+
success|skipped) ;;
364+
*) echo "runtime/publish parity failed"; exit 1 ;;
365+
esac
366+
done
367+
279368
# Experimental, NON-gating: arm64 runtime via QEMU emulation. arm64 runtime is
280369
# unsupported for now, so its cases are skipped-arm64 in the gate above. This job
281370
# is SLOW (QEMU) and only runs on a manual trigger (workflow_dispatch), not on
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"strconv"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestDailyChangedScript covers scripts/daily-changed.sh, which gates the daily
13+
// scheduled parity run on "did the repo change today?": it must emit run=true iff
14+
// HEAD's commit is within the look-back window. Getting the boundary wrong either
15+
// wastes billed runner minutes (false positive) or silently skips the daily
16+
// backstop on a day that had commits (false negative).
17+
func TestDailyChangedScript(t *testing.T) {
18+
script, err := filepath.Abs(filepath.Join("..", "..", "scripts", "daily-changed.sh"))
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
if _, err := os.Stat(script); err != nil {
23+
t.Fatalf("script missing: %v", err)
24+
}
25+
26+
// Fixed "now" so the test is deterministic regardless of wall clock.
27+
const now = 2_000_000_000
28+
29+
cases := []struct {
30+
name string
31+
commitAgeH int
32+
window string // arg to the script; "" uses the script default (25h)
33+
wantRun bool
34+
}{
35+
{name: "fresh commit runs", commitAgeH: 1, wantRun: true},
36+
{name: "just inside default window", commitAgeH: 24, wantRun: true},
37+
{name: "just outside default window", commitAgeH: 26, wantRun: false},
38+
{name: "day-old with no changes skips", commitAgeH: 48, wantRun: false},
39+
{name: "custom window widens", commitAgeH: 30, window: "72", wantRun: true},
40+
{name: "custom window narrows", commitAgeH: 2, window: "1", wantRun: false},
41+
}
42+
43+
for _, tc := range cases {
44+
t.Run(tc.name, func(t *testing.T) {
45+
repo := newRepoWithCommitAt(t, now-tc.commitAgeH*3600)
46+
47+
args := []string{script}
48+
if tc.window != "" {
49+
args = append(args, tc.window)
50+
}
51+
cmd := exec.Command("bash", args...)
52+
cmd.Dir = repo
53+
cmd.Env = append(os.Environ(), "DAILY_NOW_EPOCH="+strconv.Itoa(now))
54+
out, err := cmd.Output()
55+
if err != nil {
56+
t.Fatalf("script failed: %v", err)
57+
}
58+
59+
want := "run=false"
60+
if tc.wantRun {
61+
want = "run=true"
62+
}
63+
if got := strings.TrimSpace(string(out)); got != want {
64+
t.Errorf("age=%dh window=%q: got %q, want %q", tc.commitAgeH, tc.window, got, want)
65+
}
66+
})
67+
}
68+
}
69+
70+
// newRepoWithCommitAt creates a throwaway git repo whose single HEAD commit has the
71+
// given committer timestamp (what `git log -1 --format=%ct` reads).
72+
func newRepoWithCommitAt(t *testing.T, epoch int) string {
73+
t.Helper()
74+
dir := t.TempDir()
75+
date := "@" + strconv.Itoa(epoch) + " +0000"
76+
env := append(os.Environ(),
77+
"GIT_AUTHOR_DATE="+date,
78+
"GIT_COMMITTER_DATE="+date,
79+
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
80+
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t",
81+
)
82+
run := func(args ...string) {
83+
cmd := exec.Command("git", args...)
84+
cmd.Dir = dir
85+
cmd.Env = env
86+
if out, err := cmd.CombinedOutput(); err != nil {
87+
t.Fatalf("git %v: %v\n%s", args, err, out)
88+
}
89+
}
90+
run("init", "-q")
91+
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("x"), 0o644); err != nil {
92+
t.Fatal(err)
93+
}
94+
run("add", "f")
95+
run("commit", "-q", "-m", "seed")
96+
return dir
97+
}

0 commit comments

Comments
 (0)