Skip to content

Commit 502613e

Browse files
TMHSDigitalclaude
andcommitted
fix(ci): release gate reads PR checks once and fails closed on gh errors
The second `gh pr checks` call ended in `|| true` with stderr discarded, so a rate limit, 5xx or network failure left `bad` empty and the gate printed "checks passed" and released. One call now returns "<count>:<non-passing checks>". Output without that shape (empty, an error page, garbage) is treated as unreadable and blocks. tests/test_release_gate.py drives the script against a stubbed gh (7 cases: direct push, red Validate, green/failing checks, gh error with no output, garbage output, zero checks) and runs in validate-harness. Falsified: disabling the count guard turns the gh-error and zero-checks cases red (2 failures); restored, 7/7 OK. Closes #322 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9764d4d commit 502613e

3 files changed

Lines changed: 104 additions & 4 deletions

File tree

‎.github/scripts/release-gate.sh‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,21 @@ if [ -z "$pr" ]; then
4242
fi
4343

4444
# gh exits non-zero while checks are pending or failing even with --json, so
45-
# judge the JSON itself (gh's built-in --jq, no jq binary needed) and treat
46-
# unreadable output as a failed gate.
47-
count=$(gh pr checks "$pr" --repo "$repo" --json name --jq 'length' 2>/dev/null) || true
45+
# judge the output, not the exit status. One call yields "<count>:<bad>", where
46+
# <bad> lists every check that did not pass or skip. Anything else means gh
47+
# could not read the checks (rate limit, 5xx, network): fail closed.
48+
out=$(gh pr checks "$pr" --repo "$repo" --json name,bucket \
49+
--jq '"\(length):" + ([.[] | select(.bucket != "pass" and .bucket != "skipping") | "\(.name)=\(.bucket)"] | join(", "))' \
50+
2>/dev/null) || true
51+
count=${out%%:*}
52+
bad=${out#*:}
53+
case "$out" in *:*) ;; *) count="" ;; esac
4854
case "$count" in
4955
''|*[!0-9]*|0)
5056
echo "::error::could not read checks for PR #$pr; not releasing"
5157
exit 1
5258
;;
5359
esac
54-
bad=$(gh pr checks "$pr" --repo "$repo" --json name,bucket --jq '[.[] | select(.bucket != "pass" and .bucket != "skipping") | "\(.name)=\(.bucket)"] | join(", ")' 2>/dev/null) || true
5560
if [ -n "$bad" ]; then
5661
echo "::error::PR #$pr has checks that did not pass: $bad; not releasing"
5762
exit 1

‎.github/workflows/validate.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,9 @@ jobs:
386386
- name: Harness unit tests
387387
run: python3 tests/smoke/test_harness.py -v
388388

389+
- name: Release gate fails closed (stubbed gh)
390+
run: python3 tests/test_release_gate.py -v
391+
389392
- name: Hero-drift tool exit-code tests
390393
run: |
391394
pip install numpy==2.2.6 Pillow==11.2.1

‎tests/test_release_gate.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Exit-code tests for .github/scripts/release-gate.sh against a stubbed gh.
2+
3+
The gate must fail closed: any gh error, empty output, or unparseable output
4+
blocks the release. Run: python tests/test_release_gate.py -v (needs bash).
5+
"""
6+
from __future__ import annotations
7+
8+
import os
9+
import shutil
10+
import subprocess
11+
import tempfile
12+
import unittest
13+
from pathlib import Path
14+
15+
REPO = Path(__file__).resolve().parent.parent
16+
GATE = REPO / ".github" / "scripts" / "release-gate.sh"
17+
# Resolve now: on Windows CreateProcess would find System32\bash.exe (WSL) first.
18+
BASH = shutil.which("bash")
19+
20+
# Fake gh: behaviour comes from env vars so each test sets one scenario.
21+
# FAKE_RUN "<status> <conclusion>" for `gh run list`
22+
# FAKE_PR PR number for `gh api .../pulls` (empty = direct push)
23+
# FAKE_CHECKS stdout for `gh pr checks`; FAKE_CHECKS_RC its exit code
24+
FAKE_GH = r"""#!/usr/bin/env bash
25+
case "$1 $2" in
26+
"run list") echo "${FAKE_RUN:-completed success}" ;;
27+
"api "*) echo "${FAKE_PR:-}" ;;
28+
"pr checks") printf '%s' "${FAKE_CHECKS:-}"; exit "${FAKE_CHECKS_RC:-0}" ;;
29+
*) echo "unexpected gh $*" >&2; exit 99 ;;
30+
esac
31+
"""
32+
33+
34+
@unittest.skipIf(BASH is None, "bash not available")
35+
class ReleaseGate(unittest.TestCase):
36+
def setUp(self):
37+
self.tmp = tempfile.TemporaryDirectory()
38+
gh = Path(self.tmp.name) / "gh"
39+
gh.write_text(FAKE_GH, encoding="utf-8", newline="\n")
40+
gh.chmod(0o755)
41+
42+
def tearDown(self):
43+
self.tmp.cleanup()
44+
45+
def gate(self, **env):
46+
full = dict(os.environ)
47+
full.update(
48+
PATH=self.tmp.name + os.pathsep + full.get("PATH", ""),
49+
GITHUB_REPOSITORY="o/r",
50+
SHA="abc123",
51+
GATE_TIMEOUT="0",
52+
GATE_POLL="0",
53+
)
54+
full.update(env)
55+
proc = subprocess.run(
56+
[BASH, GATE.as_posix()], env=full, capture_output=True, text=True
57+
)
58+
return proc.returncode, proc.stdout + proc.stderr
59+
60+
def test_direct_push_with_green_validate_passes(self):
61+
self.assertEqual(self.gate(FAKE_PR="")[0], 0)
62+
63+
def test_red_validate_blocks(self):
64+
code, out = self.gate(FAKE_RUN="completed failure")
65+
self.assertEqual(code, 1)
66+
self.assertIn("Validate concluded 'failure'", out)
67+
68+
def test_all_checks_green_passes(self):
69+
code, out = self.gate(FAKE_PR="7", FAKE_CHECKS="3:")
70+
self.assertEqual(code, 0, out)
71+
self.assertIn("3 checks passed", out)
72+
73+
def test_failing_check_blocks(self):
74+
code, out = self.gate(FAKE_PR="7", FAKE_CHECKS="3:smoke=fail", FAKE_CHECKS_RC="1")
75+
self.assertEqual(code, 1)
76+
self.assertIn("smoke=fail", out)
77+
78+
def test_gh_error_with_no_output_blocks(self):
79+
code, out = self.gate(FAKE_PR="7", FAKE_CHECKS="", FAKE_CHECKS_RC="1")
80+
self.assertEqual(code, 1)
81+
self.assertIn("could not read checks", out)
82+
83+
def test_garbage_output_blocks(self):
84+
code, _ = self.gate(FAKE_PR="7", FAKE_CHECKS="HTTP 502 Bad Gateway")
85+
self.assertEqual(code, 1)
86+
87+
def test_zero_checks_blocks(self):
88+
self.assertEqual(self.gate(FAKE_PR="7", FAKE_CHECKS="0:")[0], 1)
89+
90+
91+
if __name__ == "__main__":
92+
unittest.main()

0 commit comments

Comments
 (0)