From f4baf2d87b06cd108e70cbbf018579fff5db4305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=A9o=20H=2E=20Petel?= <113530345+fraware@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:50:05 -0700 Subject: [PATCH 1/5] fix: type all consumer workflow dispatch inputs --- .github/workflows/consumer-pin-verification.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/consumer-pin-verification.yml b/.github/workflows/consumer-pin-verification.yml index 4e7cb24a..47710521 100644 --- a/.github/workflows/consumer-pin-verification.yml +++ b/.github/workflows/consumer-pin-verification.yml @@ -14,10 +14,12 @@ on: description: Ref for ovk-consumer-fastapi-terraform required: true default: main + type: string express_ref: description: Ref for ovk-consumer-express-actions required: true default: main + type: string permissions: contents: read From 7c80f9de1559b838f5fb85648f492ed983d519ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=A9o=20H=2E=20Petel?= <113530345+fraware@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:50:23 -0700 Subject: [PATCH 2/5] test: enforce typed workflow dispatch inputs --- tests/test_workflow_dispatch_contracts.py | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_workflow_dispatch_contracts.py diff --git a/tests/test_workflow_dispatch_contracts.py b/tests/test_workflow_dispatch_contracts.py new file mode 100644 index 00000000..999f3075 --- /dev/null +++ b/tests/test_workflow_dispatch_contracts.py @@ -0,0 +1,61 @@ +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows" +ALLOWED_DISPATCH_TYPES = {"boolean", "choice", "environment", "number", "string"} + + +def _load_workflow(path: Path) -> dict: + """Load workflow YAML without YAML 1.1 coercion of the `on` key.""" + + payload = yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + assert isinstance(payload, dict), f"workflow must be a mapping: {path}" + return payload + + +def test_all_workflow_dispatch_inputs_have_explicit_supported_types() -> None: + """GitHub rejects workflow_dispatch schemas whose inputs omit `type`.""" + + checked = 0 + for path in sorted(WORKFLOW_DIR.glob("*.y*ml")): + workflow = _load_workflow(path) + triggers = workflow.get("on") + if not isinstance(triggers, dict): + continue + dispatch = triggers.get("workflow_dispatch") + if dispatch is None: + continue + checked += 1 + if dispatch == "": + continue + assert isinstance(dispatch, dict), f"workflow_dispatch must be a mapping or null: {path}" + inputs = dispatch.get("inputs", {}) + if inputs == "": + continue + assert isinstance(inputs, dict), f"workflow_dispatch.inputs must be a mapping: {path}" + for input_name, spec in inputs.items(): + assert isinstance(spec, dict), f"dispatch input {input_name!r} must be a mapping: {path}" + input_type = spec.get("type") + assert input_type in ALLOWED_DISPATCH_TYPES, ( + f"dispatch input {input_name!r} in {path} must declare a supported type; got {input_type!r}" + ) + + assert checked > 0, "expected at least one workflow_dispatch workflow" + + +def test_consumer_pin_verification_exposes_typed_release_inputs() -> None: + """The release-authority consumer workflow must remain dispatchable by GitHub.""" + + path = WORKFLOW_DIR / "consumer-pin-verification.yml" + workflow = _load_workflow(path) + dispatch = workflow["on"]["workflow_dispatch"] + inputs = dispatch["inputs"] + + assert set(inputs) == {"ovk_candidate_sha", "fastapi_ref", "express_ref"} + assert all(inputs[name]["type"] == "string" for name in inputs) + assert inputs["ovk_candidate_sha"]["required"] == "true" + assert inputs["fastapi_ref"]["default"] == "main" + assert inputs["express_ref"]["default"] == "main" From 394d1260a773088b668a1d8da526e3f2becb4180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=A9o=20H=2E=20Petel?= <113530345+fraware@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:53:52 -0700 Subject: [PATCH 3/5] fix: quote consumer workflow step containing colon --- .github/workflows/consumer-pin-verification.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/consumer-pin-verification.yml b/.github/workflows/consumer-pin-verification.yml index 47710521..1380cac6 100644 --- a/.github/workflows/consumer-pin-verification.yml +++ b/.github/workflows/consumer-pin-verification.yml @@ -55,11 +55,11 @@ jobs: ref: ${{ matrix.ref }} path: consumer - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@a26af69be951213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - - name: Assert immutable Action SHA pin (no uses: ./) + - name: "Assert immutable Action SHA pin (no uses: ./)" env: OVK_SHA: ${{ inputs.ovk_candidate_sha }} working-directory: consumer From fff482f0b1f41f46e283bc1fc6958eafbe927ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=A9o=20H=2E=20Petel?= <113530345+fraware@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:06:39 -0700 Subject: [PATCH 4/5] fix: restore immutable setup-python action pin --- .github/workflows/consumer-pin-verification.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/consumer-pin-verification.yml b/.github/workflows/consumer-pin-verification.yml index 1380cac6..b84e5b65 100644 --- a/.github/workflows/consumer-pin-verification.yml +++ b/.github/workflows/consumer-pin-verification.yml @@ -55,7 +55,7 @@ jobs: ref: ${{ matrix.ref }} path: consumer - - uses: actions/setup-python@a26af69be951213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" From 70712353ec1a65eb4d564d220d091d53955c5a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=A9o=20H=2E=20Petel?= <113530345+fraware@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:07:00 -0700 Subject: [PATCH 5/5] test: enforce immutable external workflow action pins --- tests/test_workflow_dispatch_contracts.py | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_workflow_dispatch_contracts.py b/tests/test_workflow_dispatch_contracts.py index 999f3075..4f743f43 100644 --- a/tests/test_workflow_dispatch_contracts.py +++ b/tests/test_workflow_dispatch_contracts.py @@ -1,3 +1,5 @@ +import re +from collections.abc import Iterator from pathlib import Path import yaml @@ -6,6 +8,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows" ALLOWED_DISPATCH_TYPES = {"boolean", "choice", "environment", "number", "string"} +FULL_COMMIT_PIN = re.compile(r"^[^@\s]+@[0-9a-fA-F]{40}$") def _load_workflow(path: Path) -> dict: @@ -16,6 +19,29 @@ def _load_workflow(path: Path) -> dict: return payload +def _iter_workflow_uses(workflow: dict) -> Iterator[str]: + """Yield job- and step-level `uses` references from a parsed workflow.""" + + jobs = workflow.get("jobs", {}) + if not isinstance(jobs, dict): + return + for job in jobs.values(): + if not isinstance(job, dict): + continue + job_uses = job.get("uses") + if isinstance(job_uses, str): + yield job_uses + steps = job.get("steps", []) + if not isinstance(steps, list): + continue + for step in steps: + if not isinstance(step, dict): + continue + step_uses = step.get("uses") + if isinstance(step_uses, str): + yield step_uses + + def test_all_workflow_dispatch_inputs_have_explicit_supported_types() -> None: """GitHub rejects workflow_dispatch schemas whose inputs omit `type`.""" @@ -46,6 +72,23 @@ def test_all_workflow_dispatch_inputs_have_explicit_supported_types() -> None: assert checked > 0, "expected at least one workflow_dispatch workflow" +def test_external_workflow_actions_use_full_commit_sha_pins() -> None: + """External actions/reusable workflows must be pinned to exact 40-hex commits.""" + + checked = 0 + for path in sorted(WORKFLOW_DIR.glob("*.y*ml")): + workflow = _load_workflow(path) + for uses in _iter_workflow_uses(workflow): + if uses.startswith("./") or uses.startswith("docker://"): + continue + checked += 1 + assert FULL_COMMIT_PIN.fullmatch(uses), ( + f"external workflow reference must use an exact 40-hex commit SHA: {path}: {uses!r}" + ) + + assert checked > 0, "expected at least one external workflow reference" + + def test_consumer_pin_verification_exposes_typed_release_inputs() -> None: """The release-authority consumer workflow must remain dispatchable by GitHub."""