Skip to content

ci(lint): enforce ready_for_review on every draft-gated workflow, fleet-wide#120

Merged
topcoder1 merged 2 commits into
mainfrom
claude/enforce-ready-for-review-rule
Jul 17, 2026
Merged

ci(lint): enforce ready_for_review on every draft-gated workflow, fleet-wide#120
topcoder1 merged 2 commits into
mainfrom
claude/enforce-ready-for-review-rule

Conversation

@topcoder1

@topcoder1 topcoder1 commented Jul 17, 2026

Copy link
Copy Markdown
Owner

⚠️ MERGE ORDER — land this LAST

This must merge after the 21 repo fix PRs. It defaults ON, so merging it first would
redden every unfixed repo on its next PR. Simulated below: on today's main it flags
exactly those 21 and no one else.

Why a check, and why here

A workflow gated on draft == false whose types: omits ready_for_review fails open:

step event result
PR opened as draft opened job skips on the gate — correct
gh pr ready ready_for_review caller not listening — NOTHING runs

The check stays skipped, GitHub counts a skipped REQUIRED context as SATISFIED, and
the PR merges green and unreviewed. Fleet policy makes DRAFT the standard auto-merge
opt-out, so every manual-merge PR is draft→ready.

Two facts drove the design:

  1. A comment did not prevent the recurrence. This has been dropped twice — reopened
    first, then ready_for_review directly below the comment warning about reopened.
  2. Central fixes are not retroactive. ci-workflows#115 and dotclaude#156 both landed
    2026-07-15; a sweep on 2026-07-16 still found 21 installed repos vulnerable. Fixing
    the reusable and the template fixes neither existing consumers nor anything installed.

So the rule is pinned to the runtime inside lint.yml — the one place that reaches
consumers without a re-install. That closes the gap that let this bug survive twice.

What lands

file role
selftest/check_draft_gate_triggers.py the checker (+ its selftest, in the existing selftest/)
lint.ymldraft-gate-triggers job runs it against the caller's workflows, every PR
pyproject.toml / uv.lock declares pyyaml (the checker parses YAML)

It parses; it does not grep. The transitive case is the one that bites: pr-review.yml
contains no draft expression at all — the gate lives in the reusable it calls, so
grepping the caller cannot see it. An absent types: is a violation too: GitHub's
defaults (opened/synchronize/reopened) exclude ready_for_review, so the default is
itself a denylist.

Defaults ON, unlike run_shellcheck and friends. Those default off to avoid style
noise on first install; this one catches a silent failure that merges unreviewed code.
Opt-out is run_draft_gate_check: false.

Self-reference: in a reusable github.repository is the caller's repo, so the job
uses the local checkout when the caller is ci-workflows itself and fetches @main
everywhere else. Without that, this very PR failed with No such file or directory
@main has no checker until this merges.

The registry was incomplete

Registering the draft-gating reusables surfaced that the known set of three was wrong.
Five reusables here gate on draft == false:

claude-review.yml   claude-author-automerge.yml   safe-paths-automerge.yml
codex-review.yml    claude-adversarial-review.yml          <-- not in any prior audit

Callers of the last two were never checked — which is how nanoclaw's pr-codex-review.yml
was missed by the original sweep (fixed in topcoder1/nanoclaw#98). test_registry_matches_repo
derives this set from the repo itself and fails on drift, so a new draft-gating reusable
can't silently create a class of violation the checker can't see.

Verification

The checker's real failure mode is passing vacuously — so the selftest is mutation-tested:

mutation result
drop codex-review.yml from the registry test_registry_matches_repo fails
regress transitive detection to a text search ✅ 5 tests fail
(restored) ✅ 33 passed

Run with this repo's own toolchain:

  • uv run pytest -q33 passed; coverage 100% (floor 99.0)
  • actionlint clean across all workflows; no ${{ }} in any description:
  • checker exits 1 on the real pre-fix pr-review.yml that was live in 20 repos, 0 on this repo
  • fleet simulation against all 30 lint.yml consumers on current main: flags exactly
    the 21 repos with open fix PRs, 9 clean, zero surprises

The first push broke three of this repo's checks (bootstrap, a collection-time
ModuleNotFoundError that would have taken the whole selftest suite down, and 92% vs the
99% floor). All three are fixed in the follow-up commit rather than papered over; the
coverage work added the on: pull_request string- and list-form cases, which are real
shapes a caller can use.

Auto-merge rationale: MANUAL MERGE — touches .github/workflows/** (prod infra), and
PRs to ci-workflows itself are always manual merge per fleet policy. The classifier applies
risk:blocked and refuses auto-merge automatically. Merge last.

🤖 Generated with Claude Code

…et-wide

A workflow gated on `draft == false` whose `types:` omits `ready_for_review` FAILS
OPEN: the PR opens as a draft, `opened` skips the job on the gate (correct), then
`gh pr ready` fires an event the caller is not listening for, so nothing runs and
the check stays `skipped` — which GitHub counts as a SATISFIED required context.
The PR merges green and UNREVIEWED. Fleet policy makes DRAFT the standard
auto-merge opt-out, so every manual-merge PR is draft->ready.

This omission has now been made twice (`reopened` dropped first, then
`ready_for_review` dropped directly below the comment warning about `reopened`),
and BOTH central fixes were non-retroactive: ci-workflows#115 and dotclaude#156
landed 2026-07-15, yet a sweep on 2026-07-16 still found 21 installed repos
vulnerable. A comment did not prevent the recurrence, and fixing the template does
not fix consumers. So the rule is pinned to the runtime, in the one place that
reaches consumers without a re-install.

Adds:

- selftest/check_draft_gate_triggers.py — the checker. YAML-parses rather than
  greps, because the transitive case (a caller with NO `draft` expression, whose
  gate lives in the reusable it calls) is invisible to grep. Treats an absent
  `types:` as a violation too: GitHub's defaults exclude ready_for_review, so the
  default is itself a denylist.

- lint.yml `draft-gate-triggers` job — runs the checker against the CALLER's
  workflows on every PR, so installed-repo drift is caught continuously rather
  than by a manual audit. Defaults ON (unlike run_shellcheck etc., which default
  off to avoid style noise) because what it catches is silent and fails open.
  Opt-out: `run_draft_gate_check: false`.

- selftest/ + .github/workflows/selftest.yml — this repo had no selftest dir.
  Covers the checker's own failure mode, which is passing VACUOUSLY: mutation-
  tested both ways — dropping a reusable from the registry fails
  test_registry_matches_repo, and regressing the transitive detection to a text
  search fails 5 tests. test_registry_matches_repo also derives the draft-gated
  reusable set from the repo itself, so the registry cannot rot when a new
  draft-gating reusable is added.

While registering the reusables, found the known set was incomplete: alongside
claude-review.yml, claude-author-automerge.yml and safe-paths-automerge.yml,
codex-review.yml and claude-adversarial-review.yml also gate on `draft == false`.
Callers of those two were not covered by any prior audit.

Verified: 26 selftests pass; the checker exits 1 on the real pre-fix caller that
was live in 20 repos, and 0 on this repo's own workflows; actionlint clean.
Simulated against all 30 lint.yml consumers on current main — flags exactly the
21 repos with open fix PRs and no one else, so this goes green fleet-wide once
those merge.

MERGE ORDER: this must land AFTER the 21 repo fix PRs, or every unfixed repo
reddens on its next PR.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@topcoder1
topcoder1 marked this pull request as ready for review July 17, 2026 04:08
@github-actions github-actions Bot added the risk:blocked Risk class: blocked label Jul 17, 2026
@github-actions

Copy link
Copy Markdown

Risk class: blocked — manual merge required.

This PR touches one of the blocked path categories from .github/risk-paths.yml (Dockerfiles, docker-compose, .github/workflows/**, **/.env*, **/secrets*, infra/, terraform/, k8s/, or the classifier config itself).

Auto-merge is refused by claude-author-automerge.yml. A maintainer should review the diff and click "Squash and merge" themselves.

(This is a policy notice, not a code-quality failure. The classify job itself does not fail — required CI checks remain authoritative for "is the code green.")

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Coverage Floor — mode: enforce

metric value
measured 100.0%
floor (current) 99.0%
target 100.0%
last bumped 2026-05-12

@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

No issues found. Checker logic is sound (handles PyYAML on:→True trap, transitive gate detection, absent-types denylist), registry is complete vs. repo (5 reusables + pr-codex-review.yml correctly excluded as a top-level caller that already lists ready_for_review), and the selftest is mutation-tested. Documented scope limits (pull_request only, one-level transitivity) are by design, not bugs.

…trap

The first push was written against a stale picture of this repo and broke three of
its checks. All three were real; fixing them here.

1. `draft-gate triggers` died with "No such file or directory". The job fetched the
   checker from ci-workflows@main, but on the PR that ADDS the checker, main has no
   such file — and on any ci-workflows PR it would test main's checker rather than
   the PR's. In a reusable, `github.repository` is the CALLER's repo, so it now
   distinguishes: use the local checkout when the caller IS ci-workflows, fetch
   @main for every consumer.

2. `Tests (Python)` died at COLLECTION with ModuleNotFoundError: no module named
   'yaml' — which takes the entire selftest suite with it, not just the new file.
   pyyaml is now declared in the dev dependency group that `uv run pytest` resolves
   (uv.lock updated to match).

3. Coverage came in at 92% against this repo's 99.0 floor. Now 100%: the added cases
   cover the `on:` shapes a real workflow can take — `on: pull_request` (string) and
   `on: [pull_request, push]` (list) are both valid and used in the wild, and a
   checker that mis-reads them silently stops checking those workflows. The __main__
   guard is `# pragma: no cover` rather than fake-covered.

Also dropped two things that fought the existing layout: selftest/conftest.py (this
repo's selftest/ is a package with __init__.py, so imports are package-qualified and
the sys.path hack was wrong), and .github/workflows/selftest.yml (tests-runner.yml
already runs `uv run pytest -q` on this repo's PRs — a second runner was redundant).

Verified with the repo's own toolchain: `uv run pytest -q` 33 passed, coverage 100%
(floor 99.0), actionlint clean across all workflows, checker exits 0 on this repo and
1 on the real pre-fix caller, and the registry-rot mutation is still caught.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

No issues found. Checker logic, registry sync, and self-reference/bootstrap handling all verified against the repo; test coverage is thorough.

@topcoder1
topcoder1 merged commit 950b3f1 into main Jul 17, 2026
14 checks passed
@topcoder1
topcoder1 deleted the claude/enforce-ready-for-review-rule branch July 17, 2026 04:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:blocked Risk class: blocked

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant