From c5f3a72ba7c44f8edac401466953fb37a0a4c88c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:04:34 +0000 Subject: [PATCH] ci: add regression guard for path-filtered required checks (#286) Issue #286: the branch ruleset's required status check "In-repo packages resolve from source" was produced by workspace-deps-check.yml, which was path-filtered to `**/package.json`. A path-filtered workflow never runs on a PR that touches none of its paths, so the check run is never created -- and GitHub treats a required-but-never-reported context as unsatisfied. Every PR that didn't touch a package.json sat permanently at "Expected -- waiting for status to be reported", with no red to click and nothing to re-run. That's why every merge needed --admin, defeating the auto-merge path CLAUDE.md documents. VERIFIED FIRST, PER INSTRUCTIONS: this exact defect no longer reproduces on master. - workspace-deps-check.yml's `on.pull_request` carries no `paths:` key today -- confirmed by reading the file at origin/master (2de4a1c). - The ruleset ("Protect Master", id 17974934) still requires the "In-repo packages resolve from source" context -- confirmed via `gh api repos/izzywdev/FuzeFront/rulesets/17974934` (branch-protection API 403'd for this token as anticipated, but the rulesets API worked). - Empirically confirmed on merged PR #818, which touches no package.json file: `In-repo packages resolve from source: success` is present in its check-runs, and the PR merged cleanly. - git blame shows the filter was already removed by #794 (2026-08-23, "ci: two required checks on master cannot deliver a correct verdict"), which chose exactly fix (a) from this issue's own writeup ("Always run, skip the work -- keeps the gate honest") over the path-filter- shim alternative, because the job is dependency-free and scans 49 manifests in under a second, so running it unconditionally costs nothing. That reasoning is sound and is not being revisited here. So there is no live code defect left in scope of #286 to fix -- #794 already applied the correct one of the two standard fixes and did not weaken the required set. What #286 leaves genuinely open is that nothing stops the exact same regression from recurring: the "no paths: filter" invariant lived only in prose (two workflow header comments) with no CI enforcement, for either of the two workflows whose comments state it (workspace-deps-check.yml, gate-sealed-keys.yml). This commit turns that prose into an enforced, dependency-free gate: - governance/required-check-triggers.json declares the (context, workflow file) pairs that must always run unconditionally on pull_request. - scripts/check-required-check-triggers.mjs scans each declared workflow for a paths:/paths-ignore: filter nested under its pull_request: trigger and fails if one is present. No YAML-parser dependency (same style as check-workspace-deps.mjs) -- a line-based indentation scan is enough for the one shape that matters, and it does not false-positive on an unrelated paths: filter under a sibling push: trigger (tested). - .github/workflows/gate-required-check-triggers.yml wires it in on every PR to master, itself with NO paths: filter -- it would be exactly the bug it exists to catch if it were path-filtered. Mutation-tested: re-adding the historical `paths: ['**/package.json', ...]` block to workspace-deps-check.yml's pull_request trigger is caught (exit 1, exact FuzeFront#286 message); the restored file passes (exit 0). Not done here, and why: promoting gate-required-check-triggers itself into the branch ruleset's required set needs ruleset-write access this session was not asked to exercise, and is a separate governance decision (which checks are required) from the workflow-level fix #286 asked for. Filed as a note for @izzywdev rather than actioned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc --- .../gate-required-check-triggers.yml | 29 +++++ governance/required-check-triggers.json | 34 +++++ scripts/check-required-check-triggers.mjs | 123 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 .github/workflows/gate-required-check-triggers.yml create mode 100644 governance/required-check-triggers.json create mode 100644 scripts/check-required-check-triggers.mjs diff --git a/.github/workflows/gate-required-check-triggers.yml b/.github/workflows/gate-required-check-triggers.yml new file mode 100644 index 00000000..bbfdfda3 --- /dev/null +++ b/.github/workflows/gate-required-check-triggers.yml @@ -0,0 +1,29 @@ +name: gate-required-check-triggers + +# Regression guard for FuzeFront#286: a branch-ruleset REQUIRED status check whose workflow +# is path-filtered on pull_request: never runs on a PR that misses its paths, so the check +# run is never created and the PR sits permanently BLOCKED (a required-but-never-reported +# context is treated as unsatisfied — no red to click, nothing to re-run). Fixed on the one +# known instance in #794 (workspace-deps-check.yml). This job keeps it fixed: it fails if +# any workflow named in governance/required-check-triggers.json regains a paths:/ +# paths-ignore: filter on pull_request:. +# +# NO paths: FILTER HERE EITHER — this gate would be exactly the bug it exists to catch if +# it were itself path-filtered. It is dependency-free (no npm install) and scans two small +# YAML files with a plain node script, so running it unconditionally costs nothing. + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + gate-required-check-triggers: + name: gate-required-check-triggers + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Check required-check workflows run unconditionally on pull_request + run: node scripts/check-required-check-triggers.mjs diff --git a/governance/required-check-triggers.json b/governance/required-check-triggers.json new file mode 100644 index 00000000..8dcc30c8 --- /dev/null +++ b/governance/required-check-triggers.json @@ -0,0 +1,34 @@ +{ + "$comment": [ + "Regression guard for FuzeFront#286: a branch-ruleset REQUIRED status check whose", + "producing workflow is path-filtered on `pull_request:` never runs — and never reports —", + "on a PR that touches none of its paths. GitHub treats a required-but-never-reported", + "context as unsatisfied, so the PR is permanently BLOCKED with no red to look at and", + "nothing to re-run. Measured against master 2026-08-27: the ruleset ('Protect Master')", + "requires both contexts below, and as of PR #794 (2026-08-23) their workflows carry no", + "`paths:`/`paths-ignore:` filter on `pull_request:` — this file plus", + "scripts/check-required-check-triggers.mjs is what keeps it that way.", + "", + "Every entry here names a required-ruleset `context` and the workflow file that produces", + "it. scripts/check-required-check-triggers.mjs fails if that workflow's `pull_request:`", + "trigger gains a `paths:`/`paths-ignore:` key. Two entries pre-date this file and already", + "state the same rule in their own header comments (workspace-deps-check.yml: 'NO `paths:`", + "FILTER, and that is load-bearing rather than an oversight'; gate-sealed-keys.yml: 'Runs on", + "every PR (no path filter) so it is safe to add to the branch ruleset's required checks').", + "This file turns that prose into something CI actually checks.", + "", + "Adding a NEW required context to the ruleset? Add its workflow here in the same PR — the", + "gate is only as complete as this list." + ], + + "workflows": [ + { + "context": "In-repo packages resolve from source", + "file": ".github/workflows/workspace-deps-check.yml" + }, + { + "context": "gate-sealed-keys", + "file": ".github/workflows/gate-sealed-keys.yml" + } + ] +} diff --git a/scripts/check-required-check-triggers.mjs b/scripts/check-required-check-triggers.mjs new file mode 100644 index 00000000..165911d0 --- /dev/null +++ b/scripts/check-required-check-triggers.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Regression guard for FuzeFront#286. +// +// The bug: the branch ruleset's REQUIRED status check "In-repo packages resolve from +// source" was produced by a workflow path-filtered to `**/package.json`. A path-filtered +// workflow does not run — the check run is never CREATED — on a PR touching none of its +// paths. GitHub treats a required-but-never-reported context as unsatisfied, so the PR sits +// permanently at "Expected — waiting for status to be reported": no red to click, no run to +// re-trigger. Fixed in #794 by dropping the `paths:` filter (the job is dependency-free and +// costs nothing to run unconditionally). This script keeps it fixed: it fails if any +// workflow named in governance/required-check-triggers.json regains a `paths:` / +// `paths-ignore:` filter on its `pull_request:` trigger. +// +// Deliberately dependency-free (no js-yaml) so it runs with a bare `node`, same as +// scripts/check-workspace-deps.mjs. Only needs to understand ONE shape: a top-level +// `on: / pull_request: / paths:` (or `paths-ignore:`) block, so a line-based indentation +// scan is enough — a real YAML parser is not needed to detect the one key that matters. + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = process.cwd() +const manifestPath = resolve(root, 'governance/required-check-triggers.json') + +let manifest +try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) +} catch (err) { + console.error(`✗ Could not read/parse ${manifestPath}: ${err.message}`) + process.exit(1) +} + +const entries = Array.isArray(manifest.workflows) ? manifest.workflows : [] +if (entries.length === 0) { + console.error(`✗ ${manifestPath} declares no workflows — this gate would be vacuous. Fix the manifest.`) + process.exit(1) +} + +// Find a `pull_request:` mapping nested under a top-level `on:` key, and report whether +// IT (not some unrelated indentation level) directly contains `paths:`/`paths-ignore:`. +// Handles both block form (`on:\n pull_request:\n paths:`) and the flow-style +// `on: pull_request` shorthand some workflows use for other triggers (which by definition +// carries no paths filter). +function findPathsFilterOnPullRequest(source) { + const lines = source.split(/\r?\n/) + const indentOf = (line) => line.match(/^[ \t]*/)[0].length + + let onIndent = -1 + let prIndent = -1 + let inPullRequest = false + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] + const line = raw.split('#')[0] // strip trailing comments before matching keys + if (!line.trim()) continue + const indent = indentOf(raw) + + if (onIndent === -1) { + if (/^on:\s*$/.test(line.trim()) || /^on:\s*\{/.test(line.trim())) { + onIndent = indent + } + continue + } + + // Left the `on:` block entirely (back to <= its own indent, and not `on:` itself). + if (indent <= onIndent && !/^on:/.test(line.trim())) { + if (!inPullRequest) continue + break + } + + if (!inPullRequest) { + if (indent > onIndent && /^pull_request:\s*$/.test(line.trim())) { + inPullRequest = true + prIndent = indent + } + continue + } + + // Inside pull_request:. A line at or below prIndent's indent (that isn't pull_request's + // own children) ends the block. + if (indent <= prIndent) break + if (/^(paths|paths-ignore):/.test(line.trim())) return true + } + return false +} + +let failures = 0 +for (const entry of entries) { + const { context, file } = entry + if (!context || !file) { + console.error(`✗ Malformed entry in ${manifestPath}: ${JSON.stringify(entry)}`) + failures++ + continue + } + const wfPath = resolve(root, file) + let source + try { + source = readFileSync(wfPath, 'utf8') + } catch { + console.error(`✗ [${context}] declared workflow does not exist: ${file}`) + failures++ + continue + } + if (findPathsFilterOnPullRequest(source)) { + console.error( + `✗ [${context}] ${file} has a paths:/paths-ignore: filter on pull_request:.\n` + + ` This context is REQUIRED on the branch ruleset — a path-filtered required check\n` + + ` never reports on a PR that misses its paths, and the PR is permanently BLOCKED\n` + + ` (FuzeFront#286). Drop the filter, or if the job must stay path-scoped, add a\n` + + ` companion job with the SAME check name that runs on the complementary condition\n` + + ` and succeeds trivially — never remove the context from the required set.` + ) + failures++ + } else { + console.log(`✓ [${context}] ${file} runs unconditionally on pull_request`) + } +} + +if (failures > 0) { + console.error(`\n${failures} required-check workflow(s) failed the trigger check.`) + process.exit(1) +} +console.log(`\n✓ All ${entries.length} required-check workflow(s) run unconditionally on pull_request.`)