From 6fb71c724d96abd5af7210baec3c48dde5d5244d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:55:25 +0100 Subject: [PATCH] feat(governance): reject workflows with duplicate YAML keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- .github/workflows/governance-reusable.yml | 22 +++++ scripts/check-workflow-duplicate-keys.py | 100 ++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100755 scripts/check-workflow-duplicate-keys.py diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index a8f24572..2264f199 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -997,6 +997,28 @@ jobs: # governance jobs on every PR estate-wide. github.sha resolves to the # same merge commit but is always fetchable. ref: ${{ github.sha }} + - name: Checkout standards for the duplicate-key check + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/standards + ref: main + path: .standards-dupkey + sparse-checkout: scripts/check-workflow-duplicate-keys.py + sparse-checkout-cone-mode: false + + - name: Duplicate YAML keys in workflows + run: | + # GitHub Actions REJECTS a workflow with duplicate keys: the run is + # `failure` with no jobs, no log and no check run. Nothing else here + # can see it, because yaml.safe_load silently keeps the LAST + # duplicate and reports success — so the file "parses" and every + # other lint passes. Measured 2026-08-05: nine workflows in hypatia + # were dead this way, including a CodeQL workflow with zero + # successful runs in its entire lifetime. + cp .standards-dupkey/scripts/check-workflow-duplicate-keys.py "$RUNNER_TEMP/" + rm -rf .standards-dupkey + python3 "$RUNNER_TEMP/check-workflow-duplicate-keys.py" .github/workflows + - name: Check SPDX headers + permissions run: | failed=0 diff --git a/scripts/check-workflow-duplicate-keys.py b/scripts/check-workflow-duplicate-keys.py new file mode 100755 index 00000000..c872c5cc --- /dev/null +++ b/scripts/check-workflow-duplicate-keys.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Reject GitHub Actions workflows containing duplicate YAML keys. + +WHY THIS EXISTS AS A SEPARATE CHECK +----------------------------------- +GitHub Actions rejects a workflow with 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 to read, and `gh pr checks` shows no row at +all. + +Nothing else in the toolchain can see this, because `yaml.safe_load` SILENTLY +KEEPS THE LAST duplicate and reports success. The file "parses". Every +ordinary validation — linters, formatters, our own sweep scripts — is +structurally blind to it. + +Measured 2026-08-05: nine workflows in `hypatia` were in this state, 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. + +USAGE +----- + check-workflow-duplicate-keys.py [PATH ...] # default: .github/workflows + +Exit 0 when clean, 1 when any duplicate is found. +""" +import glob +import os +import sys + +import yaml + + +class StrictLoader(yaml.SafeLoader): + """A SafeLoader that refuses duplicate mapping keys instead of silently + keeping the last one.""" + + +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 + + +StrictLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicates +) + + +def check(path): + """Return a problem string, or None when the file is fine.""" + 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 + + +def main(argv): + targets = argv[1:] or [".github/workflows"] + files = [] + for t in targets: + if os.path.isdir(t): + for ext in ("yml", "yaml"): + files.extend(sorted(glob.glob(os.path.join(t, f"*.{ext}")))) + else: + files.append(t) + + failed = 0 + for f in files: + problem = check(f) + if problem: + print(f"::error file={f}::{problem}") + print(f"FAIL {f}: {problem}") + failed += 1 + + if failed: + print(f"\n{failed} of {len(files)} workflow file(s) contain duplicate keys.") + print("GitHub Actions rejects these before any job is created — they") + print("fail with no log and no check run. yaml.safe_load does NOT") + print("catch this; it keeps the last duplicate and reports success.") + return 1 + + print(f"duplicate-key check: {len(files)} workflow file(s) clean") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))