Skip to content

Commit ca4d67e

Browse files
ci(github-action): gate PRs on the data-loss and outage tests only (#2153)
* ci(github-action): gate PRs on the data-loss and outage tests only * ci(github-action): harden the test gate after review
1 parent 60ace66 commit ca4d67e

19 files changed

Lines changed: 271 additions & 26 deletions

‎.agents/skills/github-ci/SKILL.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ and credential-less `terraform/`. `flate` and `image-pull` only see
7272
## Verify
7373

7474
- One gate: `python3 scripts/ci/<name>-test.py`.
75-
- The set: `for f in scripts/ci/*-test.py; do python3 "$f" || echo "FAILED: $f"; done`.
75+
- The PR gate: `for f in $(grep -lx '# ci: gate' scripts/ci/*-test.py); do python3 "$f" || echo "FAILED: $f"; done`.
76+
The rest run in the non-blocking `python-tests-advisory` job ([scripts/ci/README.md](../../../scripts/ci/README.md)).
7677
- Docs gates (`docs-budget`, `doc-links`, `functional-comments-guard`) run in
7778
the `docs-guards` job. `functional-comments-guard` needs `DIFF_GUARD_BASE`.
7879
- Live ruleset: `gh api repos/Aviator-Coding/home-ops/rulesets/21250320`.

‎.github/workflows/validate.yaml‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,57 @@ jobs:
261261
exit "${fail}"
262262
263263
python-tests:
264+
# The PR gate: only scripts/ci files marked `# ci: gate` (at most 15, each naming
265+
# the data loss or outage it prevents; scripts/ci/ci-test-gate-test.py enforces it).
266+
# Every other test runs in python-tests-advisory. always() keeps this job running
267+
# when a sibling is skipped or failed; filter must still have succeeded.
268+
if: ${{ always() && !cancelled() && needs.filter.result == 'success' && github.event.pull_request.head.repo.full_name == github.repository && needs.filter.outputs.pythontests != '[]' }}
269+
needs:
270+
- filter
271+
name: python-tests
272+
runs-on: gha-runner-scale-set-aviator-coding-home-ops
273+
# Its Setup Tools fans out beside the other mise jobs, so it takes their 25m floor
274+
# (Setup Tools was cancelled at 602s under this fan-out on PR #1481).
275+
timeout-minutes: 25
276+
steps:
277+
- name: Checkout
278+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
279+
with:
280+
persist-credentials: false
281+
282+
# Only what gate files call: kubectl/kustomize, promtool, jq (pvc-mover-readable-check
283+
# SKIPs its CronJob-script cases without jq). Versions from .mise.toml; do not add
284+
# @version here (scripts/ci/mise-install-args-test.py).
285+
- name: Setup Tools
286+
uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0
287+
with:
288+
install_args: python uv kubectl aqua:kubernetes-sigs/kustomize aqua:prometheus/prometheus aqua:jqlang/jq
289+
290+
# Gate tests are offline; boto3 is for volsync-retired-expiry-test.py.
291+
- name: Install Python Test Dependencies
292+
run: uv pip install --system pyyaml boto3
293+
294+
# mapfile + count check: a process substitution's exit status is ignored, so an
295+
# empty or failed grep would otherwise run zero tests and pass.
296+
- name: Run gate scripts/ci tests
297+
run: |
298+
mapfile -t gates < <(grep -lx '# ci: gate' scripts/ci/*-test.py)
299+
if (( ${#gates[@]} == 0 )); then
300+
echo "FAILED: no '# ci: gate' files found"
301+
exit 1
302+
fi
303+
fail=0
304+
for f in "${gates[@]}"; do
305+
echo "::group::${f}"
306+
if ! python3 "${f}" </dev/null; then
307+
echo "FAILED: ${f}"
308+
fail=1
309+
fi
310+
echo "::endgroup::"
311+
done
312+
exit "${fail}"
313+
314+
python-tests-advisory:
264315
# always() keeps this job running when a sibling is skipped (path-filter
265316
# miss) or failed; without it, needs: on skipped jobs would skip us too
266317
# and drop scripts/ci coverage. filter must still have succeeded. A
@@ -276,7 +327,10 @@ jobs:
276327
- renovate-config
277328
- terraform
278329
- docs-guards
279-
name: python-tests
330+
name: python-tests-advisory
331+
# Advisory: never blocks a PR and is not in success.needs. Owner decision 2026-10-31
332+
# (docs/goals/2026-10-30-agent-maintainable-home-ops.md). P3 keeps or deletes each test.
333+
continue-on-error: true
280334
runs-on: gha-runner-scale-set-aviator-coding-home-ops
281335
# 20m is ~2.5x the contended success time (469s). Skill `github-ci` references/workflows.md.
282336
timeout-minutes: 20
@@ -350,17 +404,17 @@ jobs:
350404
# test_* functions and prints [PASS]/[FAIL] lines itself. See scripts/ci/README.md.
351405
# MINIJINJA_CONFIG_FILE is set by .mise.toml via mise-action env; schematic render
352406
# needs it the same way just talos / talos-validate.sh do.
353-
- name: Run scripts/ci Python Regression Tests
407+
- name: Run advisory scripts/ci tests
354408
run: |
355409
fail=0
356-
for f in scripts/ci/*-test.py; do
410+
while IFS= read -r f; do
357411
echo "::group::${f}"
358-
if ! python3 "${f}"; then
412+
if ! python3 "${f}" </dev/null; then
359413
echo "FAILED: ${f}"
360414
fail=1
361415
fi
362416
echo "::endgroup::"
363-
done
417+
done < <(grep -Lx '# ci: gate' scripts/ci/*-test.py)
364418
exit "${fail}"
365419
366420
success:

‎docs/goals/2026-10-30-agent-maintainable-home-ops.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ row in §8 and a new date, not silent drift.
183183

184184
- [x] 0.0 Merge or drop `d2fa343c` (the image script's OpenRouter key-limit note, on
185185
`feat/image-generation-skill` but not on `main`), so P0 starts from a clean `main`.
186-
- [ ] 0.1 Point the cilium, openebs and both intel-device-plugins OCIRepositories at
186+
- [x] 0.1 Point the cilium, openebs and both intel-device-plugins OCIRepositories at
187187
their upstream OCI registries (onedr0p uses `oci://quay.io/cilium/charts/cilium`;
188188
confirm each tag exists upstream before switching). Tier `risk:high`. In the same
189189
issue, list every remaining `home-operations/charts-mirror` source and Renovate
@@ -288,3 +288,6 @@ Add a dated row whenever an exit check or a definition-of-done criterion is meas
288288
| 2026-10-31 | 0.0 | `d2fa343c` landed on `main` as #2146 | #2146 |
289289
| 2026-10-31 | 0.1 (remaining sources) | The 8 other `charts-mirror` sources (headlamp, external-dns ×2, tailscale-operator, volsync, descheduler, metrics-server, keda) all pull fine from the mirror. None has an anonymously pullable official OCI chart: ghcr.io and quay.io return 403, and registry.k8s.io returns not found. Each has an official HTTPS Helm repo (kubernetes-sigs.github.io/{headlamp,external-dns,descheduler,metrics-server}, pkgs.tailscale.com/helmcharts, backube.github.io/helm-charts, kedacore.github.io/charts). Per the plan they stay on the mirror; `FluxResourceNotReadyTooLong` (task 0.5) will catch the next 403. Renovate presets (`Aviator-Coding/mortyops`, local `.renovate/`) are not community presets: no change | fix/upstream-charts PR |
290290
| 2026-10-31 | Plan change | Owner decision: gate on a few tests now. Evidence: in 59 of the last 60 Validate PR runs, `python-tests` was the only failing job (talos, bootstrap, terraform, renovate-config and docs-guards never failed). Recent causes were brittleness, not catches: the plex tag canary (#2149), the charts-mirror 403 and a factory.talos.dev SSL error. Tasks 1.1 and 1.2 now gate only `# ci: gate` tests; P3 test triage moves up to follow P1 | this file |
291+
| 2026-10-31 | 0.1 | #2147 merged. `flux get sources oci -A` shows 0 not Ready (cilium `1.18.6@sha256:e6af2ea4`, openebs `4.4.0@sha256:5aaf304b`); HelmRepository `system/intel` Ready; all 4 HelmReleases Ready on the same versions; `kube-system/ds/cilium` generation 13 before and after (3/3 ready); GpuDevicePlugin `xe` 3/3 | #2147 |
292+
| 2026-10-31 | 0.5 / D9 | #2148 merged first, so the 4 real charts-mirror OCIRepositories became the test alert: `FluxResourceNotReadyTooLong` pending at 00:37, firing at 01:07 for all 4, resolved after #2147. Alertmanager routes it to `pushover-warning`; Pushover sent 17 notifications in the last 90m with 0 failures. Still open: the owner's confirmation that the notification arrived | #2148 |
293+
| 2026-10-31 | 1.1 gate set | The owner approved the 12-test gate list from the Opus classification (gate 12, nightly 11, advisory 69). With the network denied, 11 of 12 pass offline locally (`volsync-retired-expiry` needs `boto3`, which the gate job installs) | Task 7b PR |

‎scripts/ci/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
Each `*-test.py` is a standalone script. The docstring is the invariant it
44
pins. CI globs the directory, so a new file needs no workflow edit.
55

6+
Only files with the line `# ci: gate` right after the shebang block a PR
7+
(`python-tests`). There are at most 15, and each docstring has a `Prevents:` line naming
8+
the data loss or outage it guards. Every other file runs in
9+
`python-tests-advisory`, which never blocks. `ci-test-gate-test.py` enforces
10+
this. A new test is advisory unless it guards data loss or an outage.
11+
612
```bash
713
python3 scripts/ci/<name>-test.py
814
```

‎scripts/ci/alert-coverage-gaps-test.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python3
2+
# ci: gate
23
"""Promtool and manifest contract for the backup-age, kopiur-policy, and tdarr alerts.
34
5+
Prevents: CNPG backup-age, WAL-archiver and vanished-kopiur-policy alerts that can never
6+
fire.
7+
48
Live Prometheus on 2026-09-26, before these rules existed:
59
610
Postgres backup timestamp was a real unix time only on the primary

‎scripts/ci/backup-silent-failure-alerting-test.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python3
2+
# ci: gate
23
"""Behavioral regression test for backup silent-failure alerting.
34
5+
Prevents: stalled VolSync movers and empty or stale kopiur backups never raising an
6+
alert.
7+
48
Pins the 2026-08-31 monitoring blind-spot close:
59
610
1. VolSync ReplicationSources stuck Synchronizing=True/SyncInProgress are

‎scripts/ci/ci-test-gate-test.py‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
# ci: gate
3+
"""Keep the PR gate on scripts/ci small and honest.
4+
5+
Prevents: the required python-tests job silently dropping the data-loss and
6+
outage tests, or growing back into a suite that is red on every PR (59 of 60
7+
Validate PR runs before 2026-10-31 failed on brittle tests, not real catches).
8+
9+
Only files carrying the exact line `# ci: gate` right after the shebang block
10+
PRs. Every other test runs in the advisory job, which never blocks. Asserts:
11+
12+
1. The gated files are exactly EXPECTED_GATE (owner-approved 2026-10-31;
13+
changing it is an owner decision), at most MAX_GATE, and each docstring
14+
has a `Prevents:` line naming the data loss or outage it guards.
15+
2. validate.yaml's `python-tests` job runs only gate files, fails when it
16+
finds none, has no continue-on-error, and is in `success` needs.
17+
3. `python-tests-advisory` runs only non-gate files, has
18+
`continue-on-error: true`, and is NOT in `success` needs.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import ast
24+
import sys
25+
from pathlib import Path
26+
27+
import yaml
28+
29+
ROOT = Path(__file__).resolve().parents[2]
30+
CI = ROOT / "scripts" / "ci"
31+
VALIDATE = ROOT / ".github" / "workflows" / "validate.yaml"
32+
MARKER = "# ci: gate"
33+
MAX_GATE = 15
34+
EXPECTED_GATE = {
35+
"alert-coverage-gaps-test.py",
36+
"backup-silent-failure-alerting-test.py",
37+
"ci-test-gate-test.py",
38+
"corrupt-claim-recreation-contract-test.py",
39+
"flux-annotation-values-test.py",
40+
"hostpolicy-ceph-selector-test.py",
41+
"kopiur-stage0-test.py",
42+
"kopiur-stage1-test.py",
43+
"kopiur-stage3-test.py",
44+
"kopiur-stage5-test.py",
45+
"pvc-mover-readable-check-test.py",
46+
"pvc-writable-check-test.py",
47+
"volsync-retired-expiry-test.py",
48+
}
49+
50+
failures: list[str] = []
51+
52+
53+
def check(cond: bool, msg: str) -> None:
54+
print(("[PASS] " if cond else "[FAIL] ") + msg)
55+
if not cond:
56+
failures.append(msg)
57+
58+
59+
def gate_files() -> list[Path]:
60+
out = []
61+
for path in sorted(CI.glob("*-test.py")):
62+
raw = path.read_bytes()
63+
second_line = raw.split(b"\n", 2)[1] if raw.count(b"\n") >= 2 else b""
64+
if second_line.endswith(b"\r"):
65+
failures.append(f"{path.name}: CRLF line 2 is invisible to `grep -x`")
66+
print(f"[FAIL] {path.name}: CRLF line 2 is invisible to `grep -x`")
67+
lines = raw.decode().splitlines()
68+
if len(lines) > 1 and lines[0].startswith("#!") and lines[1] == MARKER:
69+
out.append(path)
70+
elif MARKER in lines:
71+
failures.append(f"{path.name}: `{MARKER}` must be line 2, right after the shebang")
72+
print(f"[FAIL] {path.name}: `{MARKER}` must be line 2, right after the shebang")
73+
return out
74+
75+
76+
def run_script(job: dict) -> str:
77+
return "\n".join(step.get("run", "") for step in job.get("steps", []))
78+
79+
80+
def main() -> int:
81+
gates = gate_files()
82+
names = {p.name for p in gates}
83+
check(len(gates) <= MAX_GATE, f"{len(gates)} gate files (<= {MAX_GATE})")
84+
check(names == EXPECTED_GATE,
85+
f"gate set matches the approved list (missing {sorted(EXPECTED_GATE - names)}, "
86+
f"unexpected {sorted(names - EXPECTED_GATE)})")
87+
for path in gates:
88+
doc = ast.get_docstring(ast.parse(path.read_text())) or ""
89+
check(
90+
any(line.startswith("Prevents:") for line in doc.splitlines()),
91+
f"{path.name} docstring has a `Prevents:` line",
92+
)
93+
94+
jobs = yaml.safe_load(VALIDATE.read_text())["jobs"]
95+
gate_job = jobs.get("python-tests") or {}
96+
advisory = jobs.get("python-tests-advisory") or {}
97+
success_needs = (jobs.get("success") or {}).get("needs") or []
98+
99+
gate_run = run_script(gate_job)
100+
check(f"grep -lx '{MARKER}'" in gate_run, "python-tests selects files by the gate marker")
101+
check("${#gates[@]} == 0" in gate_run, "python-tests fails when it selects no gate files")
102+
check("continue-on-error" not in gate_job, "python-tests has no continue-on-error")
103+
check("python-tests" in success_needs, "python-tests is in success.needs")
104+
105+
adv_run = run_script(advisory)
106+
check(f"grep -Lx '{MARKER}'" in adv_run, "python-tests-advisory selects the non-gate files")
107+
check(advisory.get("continue-on-error") is True, "python-tests-advisory has continue-on-error: true")
108+
check("python-tests-advisory" not in success_needs, "python-tests-advisory is not in success.needs")
109+
110+
print(f"Summary: {len(failures)} failed")
111+
return 1 if failures else 0
112+
113+
114+
if __name__ == "__main__":
115+
sys.exit(main())

‎scripts/ci/corrupt-claim-recreation-contract-test.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python3
2+
# ci: gate
23
"""Semantic regression test for the corrupt-claim recreation contract.
34
5+
Prevents: recreating a claim from a frozen `latestImage` that restores nothing while
6+
every status stays green.
7+
48
THE CENTRAL CONTRACT: deleting a PVC and letting Flux recreate it from
59
dataSourceRef does NOT restore from the restic repository. The populator
610
clones ${APP}-dst.status.latestImage, and ${APP}-dst is trigger manual

‎scripts/ci/flux-annotation-values-test.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python3
2+
# ci: gate
23
"""Guard: every kustomize.toolkit.fluxcd.io/* annotation carries a value Flux honours.
34
5+
Prevents: a kustomize.toolkit.fluxcd.io/* annotation with a value Flux ignores (for
6+
example `prune: "false"`), which silently removes prune or SSA protection.
7+
48
Measured 2026-09-28 on ai/litellm-pgvector: its db-init Job carried
59
`kustomize.toolkit.fluxcd.io/force: "true"`. kustomize-controller (v1.9.4,
610
api/v1/kustomization_types.go) only acts on the exact value `enabled`, so the

‎scripts/ci/hostpolicy-ceph-selector-test.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python3
2+
# ci: gate
23
"""Semantic regression test for the Ceph LAN-isolation host CCNP selector.
34
5+
Prevents: a host-firewall CiliumClusterwideNetworkPolicy that locks out all three nodes
6+
(default-deny ingress) or never attaches to its host endpoints.
7+
48
Background (measured 2026-08-24 on talos-1/2/3): Cilium's default label filter
59
strips several well-known node labels from the *host endpoint* label set. A
610
CCNP nodeSelector that only matches stripped labels is accepted (VALID:True)

0 commit comments

Comments
 (0)