From 0d505a4570a6471e927a09a654b5ef8a0d9f1e77 Mon Sep 17 00:00:00 2001 From: forkwright Date: Sat, 1 Aug 2026 14:29:34 +0000 Subject: [PATCH] fix(hybrid-gate): diff the docs-only changeset from the merge-base, not the base tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git diff A..B` is not range notation — it is a plain comparison of two endpoints, identical to `git diff A B`. The docs-only step fed it `origin/${BASE_REF}..HEAD`, so against a base that keeps moving it reported every file main changed since the branch forked, not the files the PR proposes. A PR touching only README.md is classified NOT docs-only the moment an unrelated .rs commit lands on main. Measured on forkwright/kanon's `docs/2254-entry-point-truth`, which changes 2 files: the two-dot range yields 39 files, 37 of them non-doc. Three-dot yields 2. The exemption can therefore only ever fire on a branch that is exactly up to date with its base, which is not a property any PR holds for long. The consequence differs by gate shape. Here the PR falls through to full-gate-build, so the cost is a wasted build. In a trailer-only gate with no build fallback the same expression hard-blocks the PR: kanon's standalone gate-attestation.yml carries the identical two-dot expression at line 52 and exits 1 with "No Gate-Passed trailer found" for a docs-only PR that its own exemption was written to let through. Only the pull_request arm changes. The push arms are correct as two-dot: before..after is precisely what that push changed, and all three were re-checked against a fixture (event.before present, all-zeros, and absent) and resolve unchanged. Verified by extracting this workflow's own range-selection block and running it against a fixture PR whose branch touches only README.md while the base moved ahead with a code change: pre-fix (origin/main): range=origin/main..HEAD files=[README.md code.rs] docs_only=false rc=1 post-fix (this branch): range=origin/main...HEAD files=[README.md] docs_only=true rc=0 The permanent assertion belongs in scripts/check_event_shape_guards.py, which scans for pull_request-only values reaching git and would cover this class directly. That script is still unmerged on fix/gate-push-event-trailer-sha, and wiring it from here would collide with that branch in actionlint.yml, so this change is scoped to the defect. Follows up #25, which introduced the event-derived range. --- .github/workflows/hybrid-gate.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hybrid-gate.yml b/.github/workflows/hybrid-gate.yml index ee1027f..405f7ea 100644 --- a/.github/workflows/hybrid-gate.yml +++ b/.github/workflows/hybrid-gate.yml @@ -234,8 +234,16 @@ jobs: # Derive the range from whichever event actually fired. EVENT_BEFORE is all-zeros # on a branch's first push and may be absent from a shallow fetch, so fall back to # the head commit alone rather than assuming it resolves. + # + # WHY three-dot on the pull_request arm and two-dot on the push arms: `git diff A..B` + # is a plain comparison of two endpoints, not the range notation it looks like, so + # against a moving base it reports every file the BASE changed since the branch + # forked. A PR touching only README.md is then classified NOT docs-only the moment + # main lands an unrelated .rs commit. `A...B` diffs from the merge-base, which is the + # changeset the PR actually proposes and what the PR's "Files changed" tab shows. + # The push arms are correct as two-dot: before..after IS what that push changed. if [ -n "$BASE_REF" ]; then - range="origin/${BASE_REF}..HEAD" + range="origin/${BASE_REF}...HEAD" elif [ -n "$EVENT_BEFORE" ] \ && git rev-parse --verify --quiet "${EVENT_BEFORE}^{commit}" >/dev/null 2>&1; then range="${EVENT_BEFORE}..HEAD"