diff --git a/.github/workflows/consumer-pin-verification.yml b/.github/workflows/consumer-pin-verification.yml index 4e7cb24a..b84e5b65 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 @@ -57,7 +59,7 @@ jobs: 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 diff --git a/tests/test_workflow_dispatch_contracts.py b/tests/test_workflow_dispatch_contracts.py new file mode 100644 index 00000000..4f743f43 --- /dev/null +++ b/tests/test_workflow_dispatch_contracts.py @@ -0,0 +1,104 @@ +import re +from collections.abc import Iterator +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"} +FULL_COMMIT_PIN = re.compile(r"^[^@\s]+@[0-9a-fA-F]{40}$") + + +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 _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`.""" + + 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_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.""" + + 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"