From 3ca9c8f573c32a4d2bd5ed2f695db70944a40b86 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 21:07:46 -0700 Subject: [PATCH 1/2] ci(lint): enforce ready_for_review on every draft-gated workflow, fleet-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow gated on `draft == false` whose `types:` omits `ready_for_review` FAILS OPEN: the PR opens as a draft, `opened` skips the job on the gate (correct), then `gh pr ready` fires an event the caller is not listening for, so nothing runs and the check stays `skipped` — which GitHub counts as a SATISFIED required context. The PR merges green and UNREVIEWED. Fleet policy makes DRAFT the standard auto-merge opt-out, so every manual-merge PR is draft->ready. This omission has now been made twice (`reopened` dropped first, then `ready_for_review` dropped directly below the comment warning about `reopened`), and BOTH central fixes were non-retroactive: ci-workflows#115 and dotclaude#156 landed 2026-07-15, yet a sweep on 2026-07-16 still found 21 installed repos vulnerable. A comment did not prevent the recurrence, and fixing the template does not fix consumers. So the rule is pinned to the runtime, in the one place that reaches consumers without a re-install. Adds: - selftest/check_draft_gate_triggers.py — the checker. YAML-parses rather than greps, because the transitive case (a caller with NO `draft` expression, whose gate lives in the reusable it calls) is invisible to grep. Treats an absent `types:` as a violation too: GitHub's defaults exclude ready_for_review, so the default is itself a denylist. - lint.yml `draft-gate-triggers` job — runs the checker against the CALLER's workflows on every PR, so installed-repo drift is caught continuously rather than by a manual audit. Defaults ON (unlike run_shellcheck etc., which default off to avoid style noise) because what it catches is silent and fails open. Opt-out: `run_draft_gate_check: false`. - selftest/ + .github/workflows/selftest.yml — this repo had no selftest dir. Covers the checker's own failure mode, which is passing VACUOUSLY: mutation- tested both ways — dropping a reusable from the registry fails test_registry_matches_repo, and regressing the transitive detection to a text search fails 5 tests. test_registry_matches_repo also derives the draft-gated reusable set from the repo itself, so the registry cannot rot when a new draft-gating reusable is added. While registering the reusables, found the known set was incomplete: alongside claude-review.yml, claude-author-automerge.yml and safe-paths-automerge.yml, codex-review.yml and claude-adversarial-review.yml also gate on `draft == false`. Callers of those two were not covered by any prior audit. Verified: 26 selftests pass; the checker exits 1 on the real pre-fix caller that was live in 20 repos, and 0 on this repo's own workflows; actionlint clean. Simulated against all 30 lint.yml consumers on current main — flags exactly the 21 repos with open fix PRs and no one else, so this goes green fleet-wide once those merge. MERGE ORDER: this must land AFTER the 21 repo fix PRs, or every unfixed repo reddens on its next PR. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/lint.yml | 50 ++++ .github/workflows/selftest.yml | 55 +++++ selftest/check_draft_gate_triggers.py | 198 +++++++++++++++ selftest/conftest.py | 12 + selftest/test_check_draft_gate_triggers.py | 265 +++++++++++++++++++++ 5 files changed, 580 insertions(+) create mode 100644 .github/workflows/selftest.yml create mode 100644 selftest/check_draft_gate_triggers.py create mode 100644 selftest/conftest.py create mode 100644 selftest/test_check_draft_gate_triggers.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 09d2646..74b0585 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,6 +14,10 @@ name: Lint # # What it checks: # - actionlint on every .github/workflows/*.yml (catches GHA-specific bugs) +# - draft-gate triggers: any workflow gated on `draft == false` (in its own jobs +# OR in a reusable it calls) must listen for `ready_for_review`, else its +# required check reports `skipped` — which GitHub counts as SATISFIED — and the +# PR merges green and UNREVIEWED. See selftest/check_draft_gate_triggers.py. # - prettier --check on the markdown glob (default **/*.md) # - shellcheck on standalone shell scripts (opt-in, default OFF) # @@ -70,6 +74,11 @@ on: required: false type: boolean default: true + run_draft_gate_check: + description: "Fail if a workflow gates on draft == false but does not listen for ready_for_review. Default true. This one defaults ON, unlike the other opt-in checks, because the failure it catches is silent and fails OPEN — a skipped required check counts as satisfied, so the PR merges unreviewed." + required: false + type: boolean + default: true run_shellcheck: description: "Enable actionlint's shellcheck integration. Default false to avoid info-level style noise on first install." required: false @@ -144,6 +153,47 @@ jobs: fi shell: bash + draft-gate-triggers: + name: draft-gate triggers + # Defaults ON (unlike run_shellcheck etc.). Those default off to avoid style noise on + # first install; this one catches a silent FAIL-OPEN — a draft-gated workflow that + # never hears `ready_for_review`, so its required check reports `skipped`, which + # GitHub counts as SATISFIED, and the PR merges unreviewed. The fleet was swept clean + # on 2026-07-16 before this landed, so ON is not expected to redden any caller. + # Same format()/loose-coercion dodge as the actionlint job above. + if: ${{ format('{0}', inputs.run_draft_gate_check) != 'false' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # The checker lives here, in ci-workflows, so consumers get fixes without a + # re-install — the exact gap that made this bug survive two central fixes + # (ci-workflows#115, dotclaude#156 both landed 2026-07-15; a sweep on 2026-07-16 + # still found 21 installed repos vulnerable, because neither was retroactive). + # ci-workflows is public, so the caller's default GITHUB_TOKEN can check it out. + # + # Pinned to @main deliberately: on ci-workflows' OWN pull_request self-test run, + # this fetches main's checker rather than the PR's. That is intended — the PR's + # checker is exercised by selftest.yml, and using main here keeps consumers and + # the self-test on identical logic. + - name: Check out the checker + uses: actions/checkout@v6 + with: + repository: topcoder1/ci-workflows + ref: main + path: .ci-workflows-checker + persist-credentials: false + + - name: Install PyYAML + run: python3 -m pip install --quiet pyyaml + shell: bash + + - name: Check draft-gate triggers + run: | + python3 .ci-workflows-checker/selftest/check_draft_gate_triggers.py \ + .github/workflows + shell: bash + prettier: name: prettier (markdown) # Skip entirely on push events — prettier-autofix handles main-branch diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml new file mode 100644 index 0000000..2bea79c --- /dev/null +++ b/.github/workflows/selftest.yml @@ -0,0 +1,55 @@ +name: Selftest + +# Runs this repo's own selftest suite (selftest/) on its PRs and on push to main. +# +# NOT a reusable: consumers get the enforcement itself via the `draft-gate-triggers` job +# in lint.yml, which runs the checker against THEIR workflows. This workflow exists to +# test the checker, using the PR's version of it — lint.yml's job deliberately pins the +# checker to @main, so without this a change to the checker would ship untested. +# +# Why the repo needs a selftest at all: the rule it enforces has been dropped twice +# (`reopened` first, then `ready_for_review` directly below the comment warning about +# `reopened`), and both central fixes were non-retroactive. Comments did not prevent the +# recurrence, so the rule is pinned to the runtime. + +on: + pull_request: + # This workflow has no `draft == false` gate anywhere, so `ready_for_review` is not + # strictly required by the rule it tests. It is listed anyway: a PR opened as a draft + # and later readied should re-report this check rather than leave a stale result, and + # a caller listing it costs nothing. (Do NOT read this as the rule being optional — + # see selftest/check_draft_gate_triggers.py.) + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "selftest/**" + - ".github/workflows/**" + - ".github/workflows/selftest.yml" + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: selftest-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + selftest: + name: selftest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install deps + run: python3 -m pip install --quiet pytest pyyaml + shell: bash + + - name: Run selftest + # rootdir is selftest/ so the checker module is importable without packaging. + run: python3 -m pytest selftest -q + shell: bash diff --git a/selftest/check_draft_gate_triggers.py b/selftest/check_draft_gate_triggers.py new file mode 100644 index 0000000..0d9a8f0 --- /dev/null +++ b/selftest/check_draft_gate_triggers.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Enforce: a workflow gated on `draft == false` MUST listen for `ready_for_review`. + +WHY THIS EXISTS +--------------- +A workflow whose job gates on `if: github.event.pull_request.draft == false`, but whose +`on.pull_request.types` omits `ready_for_review`, FAILS OPEN: + + 1. PR opened as a draft -> `opened` fires -> job skips on the gate (correct) + 2. `gh pr ready` -> `ready_for_review` fires -> caller is not listening + 3. nothing runs; the check stays `skipped` + +GitHub counts a **skipped REQUIRED context as SATISFIED**, so the PR merges green and +UNREVIEWED. Fleet policy makes DRAFT the standard auto-merge opt-out, so every +manual-merge PR is draft->ready -- the recommended workflow was the one guaranteed to skip +the review. (It can also fail CLOSED, leaving a PR stuck on "Expected -- Waiting for status +to be reported", when no draft-phase run fired at all: domain-rank#33.) + +WHY A CHECK AND NOT A COMMENT +----------------------------- +This exact mistake has been made twice fleet-wide: `reopened` was dropped first, then +`ready_for_review` was dropped *directly below the comment warning about `reopened`*. A +comment did not prevent the recurrence. And fixing it centrally does not fix consumers: +ci-workflows#115 and dotclaude#156 both landed 2026-07-15, yet a sweep on 2026-07-16 still +found 21 installed repos vulnerable, because neither central fix is retroactive. This check +runs inside the `lint.yml` reusable, against the CALLER's workflows, so installed-repo drift +is caught on every PR rather than by a periodic manual audit. + +WHY IT PARSES INSTEAD OF GREPPING +--------------------------------- +The transitive case is the one that bites: a caller like `pr-review.yml` contains NO `draft` +expression anywhere -- the gate lives in the reusable it calls. Grepping the caller cannot +see it. An explicit `types:` list is a DENYLIST BY OMISSION, and GitHub's default types +(opened/synchronize/reopened) exclude `ready_for_review`, so an ABSENT list is unsafe too. + +Usage: + check_draft_gate_triggers.py [--extra-reusable name.yml ...] + +Exits 0 when clean, 1 when any violation is found (annotated for GitHub Actions). +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import yaml + +# GitHub's default `pull_request` activity types. `ready_for_review` is NOT among them -- +# which is why an absent `types:` list is not a safe default for a draft-gated workflow. +GITHUB_DEFAULT_PR_TYPES: tuple[str, ...] = ("opened", "synchronize", "reopened") + +# Reusables in topcoder1/ci-workflows whose jobs gate on `draft == false`. A caller inherits +# that gate transitively. Kept in sync with the repo by test_registry_matches_repo in +# selftest/test_check_draft_gate_triggers.py, which fails if a new draft-gating reusable is +# added here without being registered -- so the registry cannot silently rot. +DRAFT_GATED_REUSABLES: frozenset[str] = frozenset( + { + "claude-review.yml", + "claude-author-automerge.yml", + "safe-paths-automerge.yml", + "codex-review.yml", + "claude-adversarial-review.yml", + } +) + +DRAFT_GATE_EXPR = "draft == false" + + +def load_workflow(path: Path) -> dict | None: + try: + doc = yaml.safe_load(path.read_text()) + except yaml.YAMLError: + return None # actionlint owns YAML validity; don't double-report + return doc if isinstance(doc, dict) else None + + +def triggers(workflow: dict) -> dict: + """Return the `on:` block. + + PyYAML resolves the bare key `on:` to the boolean True (YAML 1.1 truthiness), so + `workflow["on"]` is a KeyError on every real GitHub workflow. Accept every spelling + rather than silently reading nothing and passing vacuously. + """ + for key in (True, "on", "On", "ON"): + if key in workflow: + raw = workflow[key] + break + else: + return {} + if isinstance(raw, str): + return {raw: {}} + if isinstance(raw, list): + return {str(k): {} for k in raw} + return raw if isinstance(raw, dict) else {} + + +def pr_types(workflow: dict) -> tuple[str, ...]: + pull_request = triggers(workflow).get("pull_request") + if not isinstance(pull_request, dict): + return GITHUB_DEFAULT_PR_TYPES + types = pull_request.get("types") + if isinstance(types, list) and types: + return tuple(str(t) for t in types) + return GITHUB_DEFAULT_PR_TYPES + + +def draft_gate_reason(workflow: dict, reusables: frozenset[str]) -> str | None: + """Why this workflow is draft-gated, or None. Checks own jobs AND called reusables.""" + jobs = workflow.get("jobs") + if not isinstance(jobs, dict): + return None + for job_id, job in jobs.items(): + if not isinstance(job, dict): + continue + if DRAFT_GATE_EXPR in str(job.get("if", "")): + return f"job `{job_id}` gates on `{DRAFT_GATE_EXPR}`" + uses = job.get("uses") + if isinstance(uses, str): + base = uses.split("@")[0].rsplit("/", 1)[-1] + if base in reusables: + return f"job `{job_id}` calls `{base}`, which gates on `{DRAFT_GATE_EXPR}`" + return None + + +def check_dir(workflows_dir: Path, reusables: frozenset[str]) -> list[str]: + violations: list[str] = [] + files = sorted(workflows_dir.glob("*.yml")) + sorted(workflows_dir.glob("*.yaml")) + for path in files: + workflow = load_workflow(path) + if workflow is None: + continue + if "pull_request" not in triggers(workflow): + continue + reason = draft_gate_reason(workflow, reusables) + if reason is None: + continue + types = pr_types(workflow) + if "ready_for_review" in types: + continue + pull_request = triggers(workflow).get("pull_request") + has_explicit_types = ( + isinstance(pull_request, dict) + and isinstance(pull_request.get("types"), list) + and bool(pull_request["types"]) + ) + listed = ( + f"types: {list(types)}" + if has_explicit_types + else f"no explicit `types:` list, so GitHub's defaults apply: {list(types)}" + ) + violations.append( + f"{path.name}: {reason}, but does not listen for `ready_for_review` " + f"({listed}). A draft PR skips that job at `opened`, and `gh pr ready` would " + f"fire nothing this workflow hears -- so its check reports `skipped`, which " + f"GitHub counts as a SATISFIED required context, and the PR merges green and " + f"unreviewed. Fix: add `ready_for_review` to the types list. " + f"See ci-workflows#115, domain-rank#35." + ) + return violations + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("workflows_dir", type=Path) + parser.add_argument( + "--extra-reusable", + action="append", + default=[], + help="Additional draft-gating reusable filename to treat as transitive.", + ) + args = parser.parse_args(argv) + + if not args.workflows_dir.is_dir(): + # Not an error: plenty of repos have no workflows dir. + print(f"no workflows directory at {args.workflows_dir}; nothing to check") + return 0 + + reusables = DRAFT_GATED_REUSABLES | set(args.extra_reusable) + violations = check_dir(args.workflows_dir, reusables) + + for v in violations: + print(f"::error file=.github/workflows/{v.split(':')[0]}::{v}") + if violations: + print( + f"\n{len(violations)} workflow(s) gate on `{DRAFT_GATE_EXPR}` without listening " + f"for `ready_for_review`. This FAILS OPEN: the required check reports `skipped`, " + f"which GitHub counts as satisfied, so the PR merges unreviewed." + ) + return 1 + + print("OK: every draft-gated workflow listens for `ready_for_review`") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/selftest/conftest.py b/selftest/conftest.py new file mode 100644 index 0000000..5825083 --- /dev/null +++ b/selftest/conftest.py @@ -0,0 +1,12 @@ +"""Make `check_draft_gate_triggers` importable when pytest is invoked from the repo root. + +selftest.yml runs `pytest selftest` from the repo root, so the checker's directory is not +on sys.path by default and the import fails at COLLECTION time — which reads as a broken +selftest rather than a failing rule. Keep this file even if it looks redundant: without it +`pytest selftest` from the root dies with ModuleNotFoundError. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) diff --git a/selftest/test_check_draft_gate_triggers.py b/selftest/test_check_draft_gate_triggers.py new file mode 100644 index 0000000..8b4ac4a --- /dev/null +++ b/selftest/test_check_draft_gate_triggers.py @@ -0,0 +1,265 @@ +# Selftest for check_draft_gate_triggers.py -- the fleet's enforcement of: +# +# a workflow gated on `draft == false` MUST listen for `ready_for_review`. +# +# Lesson 2026-07-15/2026-07-16: this omission has now been made TWICE fleet-wide +# (`reopened` dropped first, then `ready_for_review` dropped directly below the comment +# warning about `reopened`), and both central fixes (ci-workflows#115, dotclaude#156) were +# non-retroactive -- a sweep the next day still found 21 installed repos vulnerable. The +# rule is pinned to the runtime here so the next omission is caught centrally. +# +# These tests protect the CHECKER. The checker's own failure mode is passing vacuously: +# if it stops seeing the transitive gate, or stops reading `on:`, every violation sails +# through and the check becomes decoration that reports green forever. Each test below +# targets one such vacuum. + +from pathlib import Path + +import pytest +import yaml + +from check_draft_gate_triggers import ( + DRAFT_GATE_EXPR, + DRAFT_GATED_REUSABLES, + GITHUB_DEFAULT_PR_TYPES, + check_dir, + draft_gate_reason, + main, + pr_types, + triggers, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" + +TRANSITIVE_CALLER = """\ +name: PR Review +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + review: + uses: topcoder1/ci-workflows/.github/workflows/claude-review.yml@main +""" + +FIXED_CALLER = """\ +name: PR Review +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] +jobs: + review: + uses: topcoder1/ci-workflows/.github/workflows/claude-review.yml@main +""" + +DIRECT_GATE = """\ +name: Direct +on: + pull_request: + types: [opened] +jobs: + build: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: [{run: 'true'}] +""" + +NO_TYPES_AT_ALL = """\ +name: Defaults +on: + pull_request: +jobs: + review: + uses: topcoder1/ci-workflows/.github/workflows/claude-review.yml@main +""" + +NO_DRAFT_GATE = """\ +name: Plain +on: + pull_request: + types: [opened] +jobs: + build: + runs-on: ubuntu-latest + steps: [{run: 'true'}] +""" + +NOT_PULL_REQUEST = """\ +name: Nightly +on: + schedule: [{cron: '0 0 * * *'}] +jobs: + build: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: [{run: 'true'}] +""" + + +def write(tmp_path: Path, name: str, body: str) -> Path: + d = tmp_path / "workflows" + d.mkdir(exist_ok=True) + (d / name).write_text(body) + return d + + +# --- the load-bearing property: the transitive gate is visible at all --------------- + + +def test_detects_gate_inherited_from_called_reusable(): + """The caller has NO `draft` text of its own -- grep cannot see this; parsing must. + + This is the whole reason the checker exists. If it ever regresses to a text search + over the caller, every transitive violation passes vacuously -- which is exactly the + shape that left 21 repos merging unreviewed PRs. + """ + caller = yaml.safe_load(TRANSITIVE_CALLER) + assert "draft" not in TRANSITIVE_CALLER, "fixture must contain no draft expression" + reason = draft_gate_reason(caller, DRAFT_GATED_REUSABLES) + assert reason is not None and "claude-review.yml" in reason + + +def test_detects_direct_gate(): + workflow = yaml.safe_load(DIRECT_GATE) + reason = draft_gate_reason(workflow, DRAFT_GATED_REUSABLES) + assert reason is not None and DRAFT_GATE_EXPR in reason + + +def test_on_key_is_read_despite_yaml_boolean_truthiness(): + """PyYAML resolves the bare key `on:` to boolean True (YAML 1.1). + + A checker that does `workflow["on"]` reads nothing on every real workflow and reports + green forever. Pin the behavior rather than trusting the idiom. + """ + workflow = yaml.safe_load(TRANSITIVE_CALLER) + assert True in workflow, "precondition: PyYAML really does key this as boolean True" + assert "pull_request" in triggers(workflow) + + +# --- the rule ---------------------------------------------------------------------- + + +def test_flags_transitive_caller_missing_ready_for_review(tmp_path): + d = write(tmp_path, "pr-review.yml", TRANSITIVE_CALLER) + violations = check_dir(d, DRAFT_GATED_REUSABLES) + assert len(violations) == 1 + assert "pr-review.yml" in violations[0] + assert "ready_for_review" in violations[0] + + +def test_accepts_caller_that_lists_ready_for_review(tmp_path): + d = write(tmp_path, "pr-review.yml", FIXED_CALLER) + assert check_dir(d, DRAFT_GATED_REUSABLES) == [] + + +def test_absent_types_list_is_a_violation(tmp_path): + """An ABSENT `types:` is not a safe default: GitHub's defaults exclude ready_for_review.""" + assert "ready_for_review" not in GITHUB_DEFAULT_PR_TYPES + d = write(tmp_path, "defaults.yml", NO_TYPES_AT_ALL) + violations = check_dir(d, DRAFT_GATED_REUSABLES) + assert len(violations) == 1 + assert "no explicit `types:` list" in violations[0] + + +def test_direct_gate_missing_trigger_is_flagged(tmp_path): + d = write(tmp_path, "direct.yml", DIRECT_GATE) + assert len(check_dir(d, DRAFT_GATED_REUSABLES)) == 1 + + +# --- must NOT over-flag: a noisy check gets disabled, which is its own failure ------ + + +def test_ignores_workflow_without_draft_gate(tmp_path): + d = write(tmp_path, "plain.yml", NO_DRAFT_GATE) + assert check_dir(d, DRAFT_GATED_REUSABLES) == [] + + +def test_ignores_non_pull_request_workflow(tmp_path): + d = write(tmp_path, "nightly.yml", NOT_PULL_REQUEST) + assert check_dir(d, DRAFT_GATED_REUSABLES) == [] + + +def test_unparseable_yaml_is_left_to_actionlint(tmp_path): + d = write(tmp_path, "broken.yml", "{{ not yaml at all") + assert check_dir(d, DRAFT_GATED_REUSABLES) == [] + + +def test_pr_types_defaults_when_types_absent(): + assert pr_types(yaml.safe_load(NO_TYPES_AT_ALL)) == GITHUB_DEFAULT_PR_TYPES + + +# --- exit codes: the check must actually fail the job ------------------------------ + + +def test_main_exits_1_on_violation(tmp_path, capsys): + d = write(tmp_path, "pr-review.yml", TRANSITIVE_CALLER) + assert main([str(d)]) == 1 + assert "::error" in capsys.readouterr().out + + +def test_main_exits_0_when_clean(tmp_path): + d = write(tmp_path, "pr-review.yml", FIXED_CALLER) + assert main([str(d)]) == 0 + + +def test_main_exits_0_when_no_workflows_dir(tmp_path): + assert main([str(tmp_path / "nope")]) == 0 + + +def test_extra_reusable_flag_extends_the_registry(tmp_path): + body = TRANSITIVE_CALLER.replace("claude-review.yml", "some-other-gated.yml") + d = write(tmp_path, "custom.yml", body) + assert check_dir(d, DRAFT_GATED_REUSABLES) == [] # unknown reusable -> not flagged + assert main([str(d), "--extra-reusable", "some-other-gated.yml"]) == 1 + + +# --- the registry must not rot ----------------------------------------------------- + + +def test_registry_matches_repo(): + """Every draft-gating `workflow_call` reusable in this repo must be registered. + + Without this, adding a new draft-gating reusable silently creates a new class of + transitive violation the checker cannot see -- the registry would rot and the check + would pass vacuously on exactly the callers it exists to protect. + """ + actual = set() + for path in sorted(WORKFLOWS_DIR.glob("*.yml")): + doc = yaml.safe_load(path.read_text()) + if not isinstance(doc, dict): + continue + on = triggers(doc) + if "workflow_call" not in on: + continue + jobs = doc.get("jobs") or {} + for job in jobs.values(): + if isinstance(job, dict) and DRAFT_GATE_EXPR in str(job.get("if", "")): + actual.add(path.name) + break + + assert actual == set(DRAFT_GATED_REUSABLES), ( + f"DRAFT_GATED_REUSABLES is out of sync with this repo.\n" + f" registered but not draft-gated reusables: {set(DRAFT_GATED_REUSABLES) - actual}\n" + f" draft-gated reusables not registered: {actual - set(DRAFT_GATED_REUSABLES)}\n" + f"A reusable missing from the registry is invisible to the transitive check, so " + f"callers of it would merge unreviewed. Add it to DRAFT_GATED_REUSABLES." + ) + + +# --- this repo's own callers obey the rule (the selftest half) --------------------- + + +def test_workflows_dir_is_discoverable(): + # Guards the two tests below: a wrong path would make them vacuously pass and + # silently retire the selftest. + assert sorted(WORKFLOWS_DIR.glob("*.yml")), f"no workflows found under {WORKFLOWS_DIR}" + + +def test_this_repos_own_workflows_obey_the_rule(): + """ci-workflows' own callers are where this rule was broken twice. Lock them. + + `pr-review.yml` here is the canonical caller the fleet is installed from, so a + regression in it propagates to every new install. + """ + violations = check_dir(WORKFLOWS_DIR, DRAFT_GATED_REUSABLES) + assert violations == [], "\n".join(violations) From 32487a9da80ad5b077b37412c664f0513f79974c Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Thu, 16 Jul 2026 21:16:16 -0700 Subject: [PATCH 2/2] fixup: fit the repo's existing selftest conventions and fix the bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first push was written against a stale picture of this repo and broke three of its checks. All three were real; fixing them here. 1. `draft-gate triggers` died with "No such file or directory". The job fetched the checker from ci-workflows@main, but on the PR that ADDS the checker, main has no such file — and on any ci-workflows PR it would test main's checker rather than the PR's. In a reusable, `github.repository` is the CALLER's repo, so it now distinguishes: use the local checkout when the caller IS ci-workflows, fetch @main for every consumer. 2. `Tests (Python)` died at COLLECTION with ModuleNotFoundError: no module named 'yaml' — which takes the entire selftest suite with it, not just the new file. pyyaml is now declared in the dev dependency group that `uv run pytest` resolves (uv.lock updated to match). 3. Coverage came in at 92% against this repo's 99.0 floor. Now 100%: the added cases cover the `on:` shapes a real workflow can take — `on: pull_request` (string) and `on: [pull_request, push]` (list) are both valid and used in the wild, and a checker that mis-reads them silently stops checking those workflows. The __main__ guard is `# pragma: no cover` rather than fake-covered. Also dropped two things that fought the existing layout: selftest/conftest.py (this repo's selftest/ is a package with __init__.py, so imports are package-qualified and the sys.path hack was wrong), and .github/workflows/selftest.yml (tests-runner.yml already runs `uv run pytest -q` on this repo's PRs — a second runner was redundant). Verified with the repo's own toolchain: `uv run pytest -q` 33 passed, coverage 100% (floor 99.0), actionlint clean across all workflows, checker exits 0 on this repo and 1 on the real pre-fix caller, and the registry-rot mutation is still caught. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/lint.yml | 31 +++++++----- .github/workflows/selftest.yml | 55 ---------------------- pyproject.toml | 5 ++ selftest/check_draft_gate_triggers.py | 2 +- selftest/conftest.py | 12 ----- selftest/test_check_draft_gate_triggers.py | 46 +++++++++++++++++- uv.lock | 38 +++++++++++++++ 7 files changed, 109 insertions(+), 80 deletions(-) delete mode 100644 .github/workflows/selftest.yml delete mode 100644 selftest/conftest.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 74b0585..a18a713 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -166,17 +166,20 @@ jobs: steps: - uses: actions/checkout@v6 - # The checker lives here, in ci-workflows, so consumers get fixes without a - # re-install — the exact gap that made this bug survive two central fixes - # (ci-workflows#115, dotclaude#156 both landed 2026-07-15; a sweep on 2026-07-16 - # still found 21 installed repos vulnerable, because neither was retroactive). - # ci-workflows is public, so the caller's default GITHUB_TOKEN can check it out. + # The checker lives in ci-workflows so consumers get fixes without a re-install -- + # the exact gap that made this bug survive two central fixes (ci-workflows#115 and + # dotclaude#156 both landed 2026-07-15; a sweep on 2026-07-16 still found 21 + # installed repos vulnerable, because neither was retroactive). # - # Pinned to @main deliberately: on ci-workflows' OWN pull_request self-test run, - # this fetches main's checker rather than the PR's. That is intended — the PR's - # checker is exercised by selftest.yml, and using main here keeps consumers and - # the self-test on identical logic. + # Self-reference: in a reusable, `github.repository` is the CALLER's repo. When the + # caller IS ci-workflows (this workflow's own pull_request self-test), the checker is + # already in the checkout above, and fetching it from @main would be wrong twice + # over: it would miss the PR's changes, and on the PR that first adds the checker + # @main does not have the file at all -- the job died with "No such file or + # directory". So: use the local copy here, fetch @main everywhere else. + # ci-workflows is public, so a consumer's default GITHUB_TOKEN can check it out. - name: Check out the checker + if: ${{ github.repository != 'topcoder1/ci-workflows' }} uses: actions/checkout@v6 with: repository: topcoder1/ci-workflows @@ -189,9 +192,15 @@ jobs: shell: bash - name: Check draft-gate triggers + env: + IS_SELF: ${{ github.repository == 'topcoder1/ci-workflows' }} run: | - python3 .ci-workflows-checker/selftest/check_draft_gate_triggers.py \ - .github/workflows + if [ "$IS_SELF" = "true" ]; then + checker=selftest/check_draft_gate_triggers.py + else + checker=.ci-workflows-checker/selftest/check_draft_gate_triggers.py + fi + python3 "$checker" .github/workflows shell: bash prettier: diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml deleted file mode 100644 index 2bea79c..0000000 --- a/.github/workflows/selftest.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Selftest - -# Runs this repo's own selftest suite (selftest/) on its PRs and on push to main. -# -# NOT a reusable: consumers get the enforcement itself via the `draft-gate-triggers` job -# in lint.yml, which runs the checker against THEIR workflows. This workflow exists to -# test the checker, using the PR's version of it — lint.yml's job deliberately pins the -# checker to @main, so without this a change to the checker would ship untested. -# -# Why the repo needs a selftest at all: the rule it enforces has been dropped twice -# (`reopened` first, then `ready_for_review` directly below the comment warning about -# `reopened`), and both central fixes were non-retroactive. Comments did not prevent the -# recurrence, so the rule is pinned to the runtime. - -on: - pull_request: - # This workflow has no `draft == false` gate anywhere, so `ready_for_review` is not - # strictly required by the rule it tests. It is listed anyway: a PR opened as a draft - # and later readied should re-report this check rather than leave a stale result, and - # a caller listing it costs nothing. (Do NOT read this as the rule being optional — - # see selftest/check_draft_gate_triggers.py.) - types: [opened, synchronize, reopened, ready_for_review] - paths: - - "selftest/**" - - ".github/workflows/**" - - ".github/workflows/selftest.yml" - push: - branches: [main] - -permissions: - contents: read - -concurrency: - group: selftest-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - selftest: - name: selftest - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install deps - run: python3 -m pip install --quiet pytest pyyaml - shell: bash - - - name: Run selftest - # rootdir is selftest/ so the checker module is importable without packaging. - run: python3 -m pytest selftest -q - shell: bash diff --git a/pyproject.toml b/pyproject.toml index a6aea57..c2c1f12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ dev = [ "pexpect>=4.9.0", "pytest", "pytest-cov", + # check_draft_gate_triggers.py parses workflow YAML rather than grepping it: + # the transitive draft gate lives in a called reusable, so the caller has no + # `draft` text to grep for. Without this declared, `uv run pytest` dies at + # COLLECTION with ModuleNotFoundError and takes the whole selftest suite with it. + "pyyaml", ] [tool.pytest.ini_options] diff --git a/selftest/check_draft_gate_triggers.py b/selftest/check_draft_gate_triggers.py index 0d9a8f0..88a3041 100644 --- a/selftest/check_draft_gate_triggers.py +++ b/selftest/check_draft_gate_triggers.py @@ -194,5 +194,5 @@ def main(argv: list[str] | None = None) -> int: return 0 -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover sys.exit(main()) diff --git a/selftest/conftest.py b/selftest/conftest.py deleted file mode 100644 index 5825083..0000000 --- a/selftest/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Make `check_draft_gate_triggers` importable when pytest is invoked from the repo root. - -selftest.yml runs `pytest selftest` from the repo root, so the checker's directory is not -on sys.path by default and the import fails at COLLECTION time — which reads as a broken -selftest rather than a failing rule. Keep this file even if it looks redundant: without it -`pytest selftest` from the root dies with ModuleNotFoundError. -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) diff --git a/selftest/test_check_draft_gate_triggers.py b/selftest/test_check_draft_gate_triggers.py index 8b4ac4a..45cb511 100644 --- a/selftest/test_check_draft_gate_triggers.py +++ b/selftest/test_check_draft_gate_triggers.py @@ -18,7 +18,7 @@ import pytest import yaml -from check_draft_gate_triggers import ( +from selftest.check_draft_gate_triggers import ( DRAFT_GATE_EXPR, DRAFT_GATED_REUSABLES, GITHUB_DEFAULT_PR_TYPES, @@ -188,6 +188,50 @@ def test_pr_types_defaults_when_types_absent(): assert pr_types(yaml.safe_load(NO_TYPES_AT_ALL)) == GITHUB_DEFAULT_PR_TYPES +# --- every `on:` shape must be readable, or the checker silently stops checking ----- + + +def test_triggers_handles_string_form(): + """`on: pull_request` is valid and used in the wild.""" + assert "pull_request" in triggers(yaml.safe_load("on: pull_request\njobs: {}\n")) + + +def test_triggers_handles_list_form(): + """`on: [pull_request, push]` is valid and used in the wild.""" + assert "pull_request" in triggers(yaml.safe_load("on: [pull_request, push]\njobs: {}\n")) + + +def test_triggers_returns_empty_when_no_on_key(): + assert triggers({"jobs": {}}) == {} + + +def test_string_form_caller_is_still_checked(tmp_path): + """A draft-gated caller using the string form must not slip through unchecked.""" + body = ( + "on: pull_request\n" + "jobs:\n" + " review:\n" + " uses: topcoder1/ci-workflows/.github/workflows/claude-review.yml@main\n" + ) + d = write(tmp_path, "stringform.yml", body) + assert len(check_dir(d, DRAFT_GATED_REUSABLES)) == 1 + + +def test_pr_types_defaults_when_types_is_empty_list(): + wf = yaml.safe_load("on:\n pull_request:\n types: []\njobs: {}\n") + assert pr_types(wf) == GITHUB_DEFAULT_PR_TYPES + + +def test_pr_types_defaults_when_pull_request_is_not_a_mapping(): + assert pr_types(yaml.safe_load("on: pull_request\njobs: {}\n")) == GITHUB_DEFAULT_PR_TYPES + + +def test_draft_gate_reason_tolerates_malformed_jobs(): + # A workflow mid-edit (or a template with a null job) must not crash the whole check. + assert draft_gate_reason({"jobs": None}, DRAFT_GATED_REUSABLES) is None + assert draft_gate_reason({"jobs": {"a": None}}, DRAFT_GATED_REUSABLES) is None + + # --- exit codes: the check must actually fail the job ------------------------------ diff --git a/uv.lock b/uv.lock index 97e53c8..051580f 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ dev = [ { name = "pexpect" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyyaml" }, ] [package.metadata] @@ -21,6 +22,7 @@ dev = [ { name = "pexpect", specifier = ">=4.9.0" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyyaml" }, ] [[package]] @@ -187,3 +189,39 @@ sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044 wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +]