diff --git a/.github/workflows/e2e-gate.yml b/.github/workflows/e2e-gate.yml index f2b55f4bb1..913f787292 100644 --- a/.github/workflows/e2e-gate.yml +++ b/.github/workflows/e2e-gate.yml @@ -1,35 +1,36 @@ name: E2E Gate (required) -# Aggregates the 4 required OSS E2E checks into one required status check. -# The owner adds ONE check name to branch protection instead of four: +# Aggregates the 12 required CI and E2E checks into one required status check. +# The owner adds ONE check name to branch protection instead of twelve: # "E2E Gate (required)" # # Polls the Check Runs API every 30s until all required checks complete. # Fails immediately if any required check fails (fast-fail). -# Passes only when all 4 required checks have a success conclusion. -# Times out after 15 min (oss-golden-path budget is 12 min -- on a cold -# runner Playwright download alone takes ~4.5 min, then C1+C5 sweeps add -# ~5 min; the previous 8-min limit caused premature timeout before the -# underlying check could pass). +# Passes only when all required checks have a success conclusion. +# Times out after 60 min (scripts/e2e_gate.py MAX_WAIT=3600). This ceiling +# exists to handle runner starvation on days with many concurrent PRs; on a +# quiet day the gate exits as soon as all checks pass, typically in 20-30 min. # -# Required checks aggregated (keep in sync with REQUIRED_CHECKS in -# scripts/apply_required_status_checks.py): +# Required checks aggregated (authoritative list: scripts/e2e_gate.py): # - OSS golden path (wheel + OpenClaw + 9 tabs) # - Cross-repo handoff (C4) # - MOAT Keystone (13-endpoint bar) # - E2E Browser Tests (critical subset) -# -# To close C6 after this PR merges: -# GitHub Settings > Branches > main > Required status checks: -# add "E2E Gate (required)" -# (1 check name instead of 4) +# - Syntax & Lint +# - API Tests (3 OS) +# - MOAT Verifier (72 tests) +# - Entitlement API tests +# - pip install matrix (x4) +# - Wheel install & asset presence +# - Store invariants (property-based) +# - Drift Bot [skip_if_unreported: only enforced when 8090 App posts] # # Tracking: vivekchand/clawmetry#4029 (C6) # # This workflow intentionally runs on pull_request only -- not on push events. # Drift Bot (8090-software-factory app) posts its commit status to PR head # SHAs only; the app never re-posts to the merge commit that lands on main. -# If the gate ran on push events, it would wait the full 35 min timeout for +# If the gate ran on push events, it would wait the full timeout for # a Drift Bot status that never arrives (confirmed on run #33224218966). # The enforcement that matters happens on the PR; no extra run is needed after # the merge. Supersedes PR #5323 which fixed the same issue via code but was @@ -51,7 +52,7 @@ jobs: e2e-gate: name: E2E Gate (required) runs-on: ubuntu-latest - timeout-minutes: 35 + timeout-minutes: 65 steps: # Required because the gate logic is a repo script now. This job had no @@ -71,7 +72,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} REPO: ${{ github.repository }} - MAX_WAIT: "1800" + MAX_WAIT: "3600" run: python3 scripts/e2e_gate.py - name: Write job summary diff --git a/scripts/e2e_gate.py b/scripts/e2e_gate.py index 1aacc51476..c3df679ad2 100644 --- a/scripts/e2e_gate.py +++ b/scripts/e2e_gate.py @@ -19,7 +19,7 @@ * an exact name (``"Syntax & Lint"``) for a single job; * an fnmatch pattern plus ``min_count`` for a matrix job, where the pattern - expands to one check per leg (``"pip install (*)"`` -> four legs). ``min_count`` + expands to one check per leg (``"pip install (*)"``) -> four legs). ``min_count`` is what stops a *shrinking* matrix from quietly passing: drop macOS from the matrix and the pattern still matches, but the count no longer does. @@ -39,7 +39,7 @@ from dataclasses import dataclass, field POLL_INTERVAL = 30 -DEFAULT_MAX_WAIT = 1800 +DEFAULT_MAX_WAIT = 3000 # 50 min default; workflow sets 3600 (60 min) # GitHub treats skipped/neutral as non-blocking; match that. PASSING = {"success", "skipped", "neutral"} @@ -59,11 +59,19 @@ class Spec: before the spec can pass. For a single job that is 1. For a matrix it is the number of legs, so removing a leg fails the gate instead of silently reducing coverage. + + ``skip_if_unreported`` marks a spec whose reporter may legitimately never + post on certain PR types (e.g. a GitHub App that only evaluates Python or + product-record changes). When True and zero check-runs/statuses have been + posted for this spec, evaluate() returns "passed" (skipped) rather than + "pending". A failing status still fails the gate -- the guard is preserved + whenever the reporter does post. """ label: str pattern: str min_count: int = 1 + skip_if_unreported: bool = False def matches(self, name: str) -> bool: return fnmatch.fnmatchcase(name, self.pattern) @@ -111,7 +119,14 @@ def matches(self, name: str) -> bool: # Unlike everything above, drift-bot is a COMMIT STATUS from the # 8090-software-factory GitHub App, not an Actions check run. It never # appears in /check-runs, which is why list_commit_statuses exists. - Spec("Drift Bot", "drift-bot"), + # + # skip_if_unreported=True: the 8090 App only posts drift-bot on PRs that + # touch Python or product-record files. Frontend-only npm bumps (dependabot + # i18next / postcss / react-router / etc.) never receive a status -- making + # this spec a guaranteed 30-min hang on every such PR. When the App does + # post (on any PR touching Python or product files), the gate still enforces + # it. Only the "never posted" case is treated as skipped. + Spec("Drift Bot", "drift-bot", skip_if_unreported=True), ] @@ -166,6 +181,14 @@ def evaluate(specs, runs): for spec in specs: matched = {n: r for n, r in best.items() if spec.matches(n)} + # When skip_if_unreported is set and no status has been posted at all, + # treat the spec as passed (skipped). A reporter that posts but fails + # is still caught below -- this only short-circuits the "never posted" + # hang that occurs when a GitHub App does not evaluate this PR type. + if spec.skip_if_unreported and not matched: + results.append(SpecResult(spec, "passed", "no status reported, treated as skipped")) + continue + failing = [ run for run in matched.values() @@ -312,7 +335,8 @@ def main(): if args.list: for spec in REQUIRED_SPECS: legs = f" x{spec.min_count}" if spec.min_count > 1 else "" - print(f"{spec.label}{legs}: {spec.pattern}") + suffix = " [skip_if_unreported]" if spec.skip_if_unreported else "" + print(f"{spec.label}{legs}: {spec.pattern}{suffix}") return 0 token = os.environ.get("GITHUB_TOKEN", "") diff --git a/tests/test_c6_required_checks_single_source.py b/tests/test_c6_required_checks_single_source.py index 2f9ed6b033..3d15ea82ec 100644 --- a/tests/test_c6_required_checks_single_source.py +++ b/tests/test_c6_required_checks_single_source.py @@ -175,9 +175,13 @@ def test_a_failed_drift_bot_fails_the_gate(): assert result.state == "failed" -def test_a_missing_drift_bot_does_not_pass_the_gate(): - """No status at all must block, not silently satisfy the spec.""" +def test_a_missing_drift_bot_skips_not_blocks(): + """When skip_if_unreported=True and no status has been posted, the spec + passes as 'skipped' so frontend-only PRs are not held waiting forever. + A real failure (status posted + failing) still blocks -- see the test above.""" gate = _gate() spec = next(s for s in gate.REQUIRED_SPECS if s.pattern == "drift-bot") + assert spec.skip_if_unreported, "Drift Bot spec must carry skip_if_unreported=True" (result,) = gate.evaluate([spec], []) - assert result.state == "pending" + assert result.state == "passed" + assert "skipped" in result.detail