feat(governance): reject workflows with duplicate YAML keys - #582
Conversation
GitHub Actions rejects a workflow containing duplicate keys outright. The run is recorded as `failure` with NO jobs, NO log and NO check run — a red mark on the board with nothing behind it, and `gh pr checks` shows no row at all. NOTHING IN THE TOOLCHAIN COULD SEE THIS. yaml.safe_load silently keeps the LAST duplicate and reports success, so the file "parses" and every other lint passes. The workflow linter, the lockfile verifier and my own sweep validation were all structurally blind to it. Measured 2026-08-05: nine workflows in `hypatia` were dead this way, including a CodeQL workflow with 18 failures, 12 startup_failures and ZERO successes in its lifetime — the repository had never once been scanned by its own scanner. Adds scripts/check-workflow-duplicate-keys.py, a SafeLoader subclass that raises on duplicate mapping keys instead of collapsing them, and wires it into the workflow-lint job of governance-reusable so every consuming repository gets it. It emits ::error file= annotations, so a failure lands on the diff rather than only in the log. The script is pulled by sparse checkout rather than inlined, matching the pattern allowlist-preflight already uses in this file: one source of truth, so the rule cannot drift between the copy that runs and the copy people read. EXPECT SOME REPOSITORIES TO GO RED. A duplicate key means those workflows are already failing — silently, with nothing to read. Making it visible is the point, and the failure predates this check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
|
| try: | ||
| with open(path, encoding="utf-8") as fh: | ||
| yaml.load(fh, StrictLoader) | ||
| except yaml.YAMLError as exc: | ||
| return str(exc).replace("\n", " ")[:160] | ||
| except OSError as exc: | ||
| return f"unreadable: {exc}" | ||
| return None |
There was a problem hiding this comment.
💡 Edge Case: Any YAML syntax error is mislabeled as a duplicate key
check() catches every yaml.YAMLError (including plain syntax/scanner errors, not just the duplicate-key error raised by _no_duplicates), and the summary printed on failure asserts the files "contain duplicate keys" and that "yaml.safe_load does NOT catch this." A file with an unrelated parse error will therefore be reported as a duplicate-key violation, misdirecting the fix. Consider raising a dedicated exception subclass from _no_duplicates and only attributing the duplicate-key message when that specific error is caught, otherwise report it as a generic parse error.
Use a distinct exception so genuine syntax errors are not mislabeled.:
class DuplicateKeyError(yaml.YAMLError):
pass
# in _no_duplicates:
if dupes:
detail = ", ".join(f"{k!r} (line {ln})" for k, ln in dupes)
raise DuplicateKeyError(f"duplicate key(s): {detail}")
# in check(): distinguish DuplicateKeyError from other YAMLError to label
# the failure accurately.
Was this helpful? React with 👍 / 👎
| def _no_duplicates(loader, node, deep=False): | ||
| mapping = {} | ||
| dupes = [] | ||
| for key_node, value_node in node.value: | ||
| key = loader.construct_object(key_node, deep=deep) | ||
| if key in mapping: | ||
| dupes.append((key, key_node.start_mark.line + 1)) | ||
| mapping[key] = loader.construct_object(value_node, deep=deep) | ||
| if dupes: | ||
| detail = ", ".join(f"{k!r} (line {ln})" for k, ln in dupes) | ||
| raise yaml.YAMLError(f"duplicate key(s): {detail}") | ||
| return mapping |
There was a problem hiding this comment.
💡 Edge Case: Unhashable complex mapping keys crash with uncaught TypeError
_no_duplicates constructs each key and does key in mapping / mapping[key] = .... If a workflow uses a complex YAML key (a mapping or sequence key), construct_object returns an unhashable dict/list and the membership/assignment raises TypeError, which is not caught by the yaml.YAMLError/OSError handlers in check() and aborts the whole run. This is unlikely in real workflows but would make the linter itself crash rather than report cleanly; wrap the dict operations or catch TypeError.
Fix:
except (yaml.YAMLError, TypeError) as exc:
return str(exc).replace("
", " ")[:160]
Was this helpful? React with 👍 / 👎
There was a problem hiding this comment.
Configure merge blocking · Maintainers can dismiss this review.
|
Note Automatic reviews are paused because your trial's included automatic processing has been used for this period. Upgrade now, or comment "Gitar review" to run a review anytime. Code Review 👍 Approved with suggestions 0 resolved / 2 findingsAdds a strict YAML loader to reject workflows with duplicate mapping keys and surface them via error annotations. Consider handling general YAML syntax errors separately and guarding against unhashable complex keys to prevent crashes.
💡 Edge Case: Any YAML syntax error is mislabeled as a duplicate key📄 scripts/check-workflow-duplicate-keys.py:60-67 📄 scripts/check-workflow-duplicate-keys.py:88-92 check() catches every yaml.YAMLError (including plain syntax/scanner errors, not just the duplicate-key error raised by _no_duplicates), and the summary printed on failure asserts the files "contain duplicate keys" and that "yaml.safe_load does NOT catch this." A file with an unrelated parse error will therefore be reported as a duplicate-key violation, misdirecting the fix. Consider raising a dedicated exception subclass from _no_duplicates and only attributing the duplicate-key message when that specific error is caught, otherwise report it as a generic parse error. Use a distinct exception so genuine syntax errors are not mislabeled.💡 Edge Case: Unhashable complex mapping keys crash with uncaught TypeError📄 scripts/check-workflow-duplicate-keys.py:39-50 _no_duplicates constructs each key and does Fix🤖 Prompt for agentsOptionsDisplay: compact → Showing less information. Comment with these commands to change the behavior for this request:
Important Your trial ends in 4 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more. Was this helpful? React with 👍 / 👎 | Gitar |
| @@ -0,0 +1,100 @@ | |||
| #!/usr/bin/env python3 | |||
…ertion (#57) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#73) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#66) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#108) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#49) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#45) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#65) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#176) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#81) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…224) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#48) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#35) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ertion (#84) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…71) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…41) **These workflow files are not valid YAML, so they have never run.** Not "ran and failed" — never ran. GitHub Actions rejects the file before creating any job: the run is recorded as `failure` with **no jobs, no log and no check run**, and `gh pr checks` shows no row at all. A red mark with nothing behind it to read. ## Cause A sweep added permission declarations **by line position rather than by parsing the document**. Three invalid shapes resulted: **A — a mapping indented under a scalar value** ```yaml permissions: read-all actions: read # read-all is a SCALAR; it cannot take children ``` `read-all` already grants everything `actions: read` would, so the orphaned line is dropped and nothing is lost. **B — injected inside another block** ```yaml on: permissions: contents: read # two colons, and illegal under `on:` anyway push: ``` **C — a literal `\n` that was never interpreted**, gluing the escape's `n` to the key: ```yaml runs-on: ubuntu-latest npermissions: # "\npermissions:" written literally ``` Only a text-level writer emitting an uninterpreted escape can produce that. ## Verified, not assumed Every workflow in this repository parses after the change. The repairer **refuses to write any file that does not parse and still contain jobs** afterwards. Where a job-level `permissions:` line was removed, a **read-only top-level `permissions:` remains**, so nothing is widened — and if none would remain, the tool reports that rather than inventing one. Guessing a permission set is how you silently over-grant. ## Estate context **67 repositories and 100 workflow files are in this state.** The most frequently broken file is **`workflow-linter.yml`, in 22 repositories** — followed by `scorecard.yml` (20) and `dogfood-gate.yml` (13). The workflow whose job is to lint workflows was itself unparseable, so **it never ran, and never caught this or anything else.** The check that would have found the damage was destroyed by the same sweep that caused it. ## So it cannot recur invisibly Detection is being added upstream: a strict-YAML check in the governance reusable — hyperpolymath/standards#582. Ordinary validation cannot see this class of fault, because `yaml.safe_load` silently accepts duplicate keys and only a full parse catches the malformed indentation. ## Expect this repository to get louder Workflows that have been failing silently will now actually run, and some will find real problems that have been invisible for as long as the files have been broken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>




GitHub Actions rejects a workflow containing duplicate keys outright. The run is recorded as
failurewith no jobs, no log and no check run — a red mark with nothing behind it, andgh pr checksshows no row at all.Nothing in the toolchain could see this
yaml.safe_loadsilently keeps the last duplicate and reports success. The file "parses", so every other lint passes. The workflow linter, the lockfile verifier, and my own sweep validation were all structurally blind to it.Measured 2026-08-05: nine workflows in
hypatiawere dead this way — including a CodeQL workflow with 18 failures, 12 startup_failures and zero successes in its entire lifetime. The repository had never once been scanned by its own scanner. Fix in hyperpolymath/hypatia#681.What this adds
scripts/check-workflow-duplicate-keys.py— aSafeLoadersubclass that raises on duplicate mapping keys instead of collapsing them — wired into theworkflow-lintjob ofgovernance-reusable, so every consuming repository gets it.It emits
::error file=annotations, so a failure lands on the diff rather than only in a log nobody opens.The script is pulled by sparse checkout rather than inlined, matching the pattern
allowlist-preflightalready uses in this same file: one source of truth, so the rule cannot drift between the copy that runs and the copy people read.Expect some repositories to go red
A duplicate key means those workflows are already failing — silently, with nothing to read. Making it visible is the point, and the failure predates this check.
I am sweeping the remaining ~242 repository checkouts before this is merged, so the blast radius is known rather than discovered. Current evidence says duplicates are concentrated almost entirely in
hypatia(15 of 16 found across 182 checkouts), but that is a hypothesis until the rest are measured.🤖 Generated with Claude Code