Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/gate-required-check-triggers.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions governance/required-check-triggers.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
123 changes: 123 additions & 0 deletions scripts/check-required-check-triggers.mjs
Original file line number Diff line number Diff line change
@@ -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.`)
Loading