Skip to content

Commit f05eaee

Browse files
aledbfclaude
andcommitted
ci(parity): scope runtime matrix to affected commands on PR, full run daily
The runtime parity matrix (~113 real Docker cases) took ~45 min on every push/PR. Split it so pushes/PRs only exercise the cases a change can affect, and move the exhaustive run to a daily schedule. - selected() (parity matrix filter) now accepts a comma-separated allowlist, so PARITY_COMMAND="up,build" runs just those commands. Empty/"all" still means everything; unselected cases are "not-selected" and do not trip the strict gate. - .github/scripts/parity-affected.sh maps a changed-file list to a PARITY_COMMAND value: isolated leaf-command files narrow to that command (e.g. exec.go -> exec, gpu.go -> up), docs-only changes -> none, and any shared/core/uncertain file (config, docker, imagemeta, features, the harness, Taskfile, workflow) -> all. Conservative by construction: the default is "all". - Taskfile: parity:runtime reads PARITY_COMMAND to scope the matrix; the heavy, network-bound TestPublishParity moves to its own parity:publish. - CI: parity-runtime now runs only on push/PR, computes the affected commands from the diff, and skips entirely when nothing runtime-relevant changed. A new parity-runtime-full job runs the whole matrix + parity:publish on a daily cron (06:00 UTC) and on manual dispatch — the backstop for any cross-command effect the scoped run misses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2ed07f9 commit f05eaee

5 files changed

Lines changed: 223 additions & 6 deletions

File tree

.github/scripts/parity-affected.sh

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bash
2+
# Map a set of changed files (read from stdin, one path per line) to the parity
3+
# runtime commands they can affect, and print a single value on stdout:
4+
#
5+
# all -> run the whole runtime matrix (a shared/core or uncertain file changed)
6+
# none -> run nothing (only docs / irrelevant files changed, or no files)
7+
# <csv> -> run only these commands, e.g. "up,build" (PARITY_COMMAND allowlist)
8+
#
9+
# The mapping is deliberately conservative: only files we are confident are
10+
# command-local narrow the run; everything else falls through to "all". The
11+
# daily full run is the backstop for any cross-command effect this misses.
12+
#
13+
# Usage: git diff --name-only base...head | .github/scripts/parity-affected.sh
14+
set -euo pipefail
15+
16+
full=0
17+
any=0
18+
declare -A cmds=()
19+
20+
add() { cmds["$1"]=1; }
21+
22+
while IFS= read -r f; do
23+
[ -z "$f" ] && continue
24+
any=1
25+
case "$f" in
26+
# --- Ignorable: never affect the runtime matrix -------------------------
27+
*.md | LICENSE* | .gitignore | .editorconfig | .github/CODEOWNERS)
28+
: ;;
29+
.github/workflows/release.yml | .goreleaser* )
30+
: ;;
31+
32+
# --- Harness / matrix data / build config → run everything --------------
33+
# (listed BEFORE the generic *_test.go ignore so it wins)
34+
docs/parity/parity-matrix.yaml | \
35+
internal/cli/parity_matrix_test.go | internal/cli/parity_matrix_helpers_test.go | \
36+
Taskfile.yml | go.mod | go.sum | \
37+
.github/workflows/go-cli.yml | .github/scripts/parity-affected.sh)
38+
full=1 ;;
39+
40+
# --- Isolated leaf command files → just that command's cases ------------
41+
internal/cli/read_configuration.go) add read-configuration ;;
42+
internal/cli/exec.go) add exec ;;
43+
internal/cli/outdated.go) add outdated ;;
44+
internal/cli/features_info.go) add features-info ;;
45+
internal/cli/templates_apply.go | internal/cli/templates_metadata.go) add templates ;;
46+
internal/cli/run_user_commands.go) add run-user-commands ;;
47+
internal/cli/setup.go) add set-up ;;
48+
internal/cli/gpu.go | internal/cli/mounts.go | internal/cli/up.go) add up ;;
49+
internal/cli/build.go | internal/cli/build_auth.go | internal/cli/cache_key.go) add build ;;
50+
internal/cli/collection_commands.go)
51+
add features; add templates; add features-info; add features-package ;;
52+
53+
# --- A command's own hermetic/e2e tests don't affect the runtime matrix -
54+
# (they run in their own jobs). Ignore them for runtime selection.
55+
internal/cli/*_test.go)
56+
: ;;
57+
58+
# --- Anything else under the source tree is shared or uncertain → full --
59+
internal/* | cmd/*)
60+
full=1 ;;
61+
62+
# --- Unknown top-level path → be safe --------------------------------------
63+
*)
64+
full=1 ;;
65+
esac
66+
done
67+
68+
if [ "$full" -eq 1 ]; then
69+
echo "all"
70+
exit 0
71+
fi
72+
if [ "${#cmds[@]}" -eq 0 ]; then
73+
# any==0 means an empty diff; either way there is nothing runtime-relevant.
74+
echo "none"
75+
exit 0
76+
fi
77+
78+
# Join the selected command keys with commas, sorted for stable output.
79+
printf '%s\n' "${!cmds[@]}" | sort | paste -sd, -

.github/workflows/go-cli.yml

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ on:
66
pull_request:
77
branches: [main, master]
88
workflow_dispatch: {}
9+
schedule:
10+
# Daily 06:00 UTC: the full runtime parity matrix + publish parity. On
11+
# push/PR only the cases affected by the change run (see parity-runtime).
12+
- cron: "0 6 * * *"
913

1014
jobs:
1115
lint-and-test:
@@ -103,9 +107,13 @@ jobs:
103107
path: artifacts/
104108
if-no-files-found: error
105109

106-
# Runtime lane of the parity matrix (creates real containers via Docker).
107-
# ubuntu-latest ships with Docker preinstalled.
110+
# Runtime lane of the parity matrix (creates real containers via Docker) on
111+
# push/PR, SCOPED to the commands affected by the change: the full matrix takes
112+
# ~45 min, so .github/scripts/parity-affected.sh maps the diff to a
113+
# PARITY_COMMAND allowlist (or "all"/"none"). The daily parity-runtime-full job
114+
# is the backstop that always runs the whole thing.
108115
parity-runtime:
116+
if: github.event_name == 'push' || github.event_name == 'pull_request'
109117
runs-on: ubuntu-latest
110118
needs: lint-and-test
111119
env:
@@ -117,12 +125,98 @@ jobs:
117125
- uses: actions/checkout@v7
118126
with:
119127
submodules: recursive
120-
# The runtime matrix builds ~180 real images serially; they accumulate and
128+
# Full history so the diff base (PR base / previous push) is present.
129+
fetch-depth: 0
130+
- name: Determine affected parity commands
131+
id: affected
132+
run: |
133+
if [ "${{ github.event_name }}" = "pull_request" ]; then
134+
base="${{ github.event.pull_request.base.sha }}"
135+
head="${{ github.event.pull_request.head.sha }}"
136+
else
137+
base="${{ github.event.before }}"
138+
head="${{ github.sha }}"
139+
fi
140+
# New branch / unknown base (e.g. all-zero before-SHA) → run everything.
141+
if [ -z "$base" ] || ! git cat-file -e "${base}^{commit}" 2>/dev/null; then
142+
echo "commands=all" >> "$GITHUB_OUTPUT"; echo "run=true" >> "$GITHUB_OUTPUT"
143+
echo "unknown base → running the full matrix"; exit 0
144+
fi
145+
files=$(git diff --name-only "$base" "$head")
146+
echo "changed files:"; echo "$files"
147+
cmds=$(printf '%s\n' "$files" | .github/scripts/parity-affected.sh)
148+
echo "affected parity commands: $cmds"
149+
echo "commands=$cmds" >> "$GITHUB_OUTPUT"
150+
if [ "$cmds" = "none" ]; then
151+
echo "run=false" >> "$GITHUB_OUTPUT"
152+
echo "no runtime-relevant changes → skipping the runtime matrix"
153+
else
154+
echo "run=true" >> "$GITHUB_OUTPUT"
155+
fi
156+
# The runtime matrix builds real images serially; they accumulate and
121157
# exhaust the runner's root disk ("No space left on device" — the runner then
122158
# dies hard, losing logs). /mnt is the SAME filesystem as / on these runners,
123159
# so it buys nothing; instead free the big preinstalled SDKs (Go/Node are
124160
# reinstalled by the setup steps that follow) for ~25-30GB of headroom. Also
125161
# enable the containerd image store (needed for cache export / --platform).
162+
- name: Free disk space + configure Docker (containerd store)
163+
if: steps.affected.outputs.run == 'true'
164+
run: |
165+
echo "before:"; df -h / | tail -1
166+
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
167+
/opt/hostedtoolcache/CodeQL /usr/share/swift \
168+
/usr/local/share/boost /usr/lib/jvm || true
169+
sudo docker image prune -af >/dev/null 2>&1 || true
170+
echo "after:"; df -h / | tail -1
171+
echo '{"features":{"containerd-snapshotter":true}}' | sudo tee /etc/docker/daemon.json
172+
sudo systemctl restart docker
173+
docker info -f 'driver={{.DriverStatus}}'
174+
- uses: actions/setup-go@v6
175+
if: steps.affected.outputs.run == 'true'
176+
with:
177+
go-version-file: go.mod
178+
- uses: actions/setup-node@v6
179+
if: steps.affected.outputs.run == 'true'
180+
with:
181+
node-version: "20"
182+
- uses: go-task/setup-task@v2
183+
if: steps.affected.outputs.run == 'true'
184+
with:
185+
version: 3.x
186+
repo-token: ${{ secrets.GITHUB_TOKEN }}
187+
188+
- if: steps.affected.outputs.run == 'true'
189+
run: task reference
190+
- if: steps.affected.outputs.run == 'true'
191+
env:
192+
PARITY_COMMAND: ${{ steps.affected.outputs.commands }}
193+
run: task parity:runtime
194+
- if: always() && steps.affected.outputs.run == 'true'
195+
run: |
196+
mkdir -p artifacts
197+
git -C reference rev-parse HEAD > artifacts/reference-commit.txt
198+
- uses: actions/upload-artifact@v7
199+
if: always() && steps.affected.outputs.run == 'true'
200+
with:
201+
name: parity-runtime-v0.88.0
202+
path: artifacts/
203+
if-no-files-found: error
204+
- if: always() && steps.affected.outputs.run == 'true'
205+
run: task clean
206+
207+
# Full runtime parity matrix + publish parity. Runs once a day (schedule) and
208+
# on manual dispatch — the exhaustive backstop for the scoped push/PR job above.
209+
parity-runtime-full:
210+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
211+
runs-on: ubuntu-latest
212+
needs: lint-and-test
213+
env:
214+
PARITY_RUNTIME_TIMEOUT: "10m"
215+
PARITY_PARALLEL: "1"
216+
steps:
217+
- uses: actions/checkout@v7
218+
with:
219+
submodules: recursive
126220
- name: Free disk space + configure Docker (containerd store)
127221
run: |
128222
echo "before:"; df -h / | tail -1
@@ -147,14 +241,15 @@ jobs:
147241

148242
- run: task reference
149243
- run: task parity:runtime
244+
- run: task parity:publish
150245
- if: always()
151246
run: |
152247
mkdir -p artifacts
153248
git -C reference rev-parse HEAD > artifacts/reference-commit.txt
154249
- uses: actions/upload-artifact@v7
155250
if: always()
156251
with:
157-
name: parity-runtime-v0.88.0
252+
name: parity-runtime-full-v0.88.0
158253
path: artifacts/
159254
if-no-files-found: error
160255
- if: always()

Taskfile.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ tasks:
130130
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-network.json PARITY_STRICT=true PARITY_LANE=all PARITY_NETWORK_ONLY=true PARITY_SKIP_DOCKER=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 15m -v {{.CLI_ARGS}}
131131

132132
parity:runtime:
133-
desc: Run the complete parity matrix with Docker
133+
desc: Run the runtime parity matrix with Docker (set PARITY_COMMAND=up,build to scope it)
134134
deps: [build, reference:compile]
135135
preconditions:
136136
- sh: docker info >/dev/null 2>&1
@@ -152,7 +152,17 @@ tasks:
152152
# -parallel default 2 for gate determinism: higher values overload the docker
153153
# daemon on contended runners and transiently fail compose/heavy cases (they
154154
# match in isolation). Raise via PARITY_PARALLEL on beefy/idle runners.
155+
# PARITY_COMMAND (comma-separated, inherited from the environment) scopes the
156+
# run to the cases affected by a change; unset/"all" runs the full matrix.
155157
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-runtime.json PARITY_STRICT=true PARITY_LANE=all go test ./internal/cli -run TestParityMatrix -count=1 -parallel {{.PARITY_PARALLEL | default "2"}} -timeout 60m -v {{.CLI_ARGS}}
158+
159+
parity:publish:
160+
desc: Run the OCI publish parity (features publish/package) — network + registry heavy
161+
deps: [build, reference:compile]
162+
preconditions:
163+
- sh: docker info >/dev/null 2>&1
164+
msg: Docker is required for publish parity
165+
cmds:
156166
- go test ./internal/cli -run TestPublishParity -count=1 -timeout 10m -v
157167

158168
clean:

internal/cli/parity_matrix_helpers_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,27 @@ func TestMatchesFilter_NetworkOnly(t *testing.T) {
4949
}
5050
}
5151

52+
func TestSelected_CommaList(t *testing.T) {
53+
cases := []struct {
54+
value, filter string
55+
want bool
56+
}{
57+
{"up", "", true}, // empty matches all
58+
{"up", "all", true}, // "all" matches all
59+
{"up", "up", true}, // single exact
60+
{"up", "build", false}, // single mismatch
61+
{"up", "up,build", true}, // list contains
62+
{"build", "up,build", true}, // list contains (2nd)
63+
{"exec", "up,build", false}, // list excludes
64+
{"build", " up , build ", true}, // whitespace tolerated
65+
}
66+
for _, c := range cases {
67+
if got := selected(c.value, c.filter); got != c.want {
68+
t.Errorf("selected(%q, %q) = %v, want %v", c.value, c.filter, got, c.want)
69+
}
70+
}
71+
}
72+
5273
func TestWriteParityReport(t *testing.T) {
5374
path := filepath.Join(t.TempDir(), "nested", "report.json")
5475
want := map[parityOutcome][]string{parityMatched: {"a"}, parityInconclusive: {"b"}}

internal/cli/parity_matrix_test.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1093,8 +1093,20 @@ func matchesFilter(tc parityCase) bool {
10931093
selected(tc.CurrentStatus, os.Getenv("PARITY_STATUS"))
10941094
}
10951095

1096+
// selected reports whether value passes a filter. An empty filter or "all"
1097+
// matches everything; otherwise the filter is a comma-separated allowlist and
1098+
// value must equal one of its (trimmed) entries. The list form lets CI run the
1099+
// cases affected by a change, e.g. PARITY_COMMAND="up,build".
10961100
func selected(value, filter string) bool {
1097-
return filter == "" || filter == "all" || value == filter
1101+
if filter == "" || filter == "all" {
1102+
return true
1103+
}
1104+
for _, f := range strings.Split(filter, ",") {
1105+
if strings.TrimSpace(f) == value {
1106+
return true
1107+
}
1108+
}
1109+
return false
10981110
}
10991111

11001112
func selectedSubstring(value, filter string) bool {

0 commit comments

Comments
 (0)