diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 5056e9a..cf6298c 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -14,9 +14,10 @@ name: actionlint # # actionlint itself doesn't fetch remote reusable workflows, so it # cannot cross-validate that a caller's input map matches this -# repo's `workflow_call.inputs:` block. It still catches the lion's -# share of input-drift incidents (typos, removed-input refs in the -# reusable workflow, expression and shell errors in both ends). +# repo's `workflow_call.inputs:` block. The companion job +# `validate-reusable-inputs` below closes that gap (PG#1045) by +# diffing every caller `with:` block against the referenced +# workflow's declared `on.workflow_call.inputs` map. on: workflow_call: {} @@ -51,3 +52,34 @@ jobs: - name: Run actionlint run: ./actionlint -color + + # Cross-repo input validation for `uses: pinpredict/.github/...@ref` + # callers. actionlint can't reach the remote workflow's + # `workflow_call.inputs` map, so a renamed/removed input still surfaces + # only at runtime as `startup_failure`. This job diffs caller `with:` + # blocks against the referenced workflow and fails on unknown or + # missing-required keys. No-op when the caller has no + # `pinpredict/.github` reusable-workflow `uses:` lines. + validate-reusable-inputs: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout caller workflows + uses: actions/checkout@v6 + + # `uses: ./...` in a reusable workflow resolves against the + # caller's checkout, not this repo — so we vendor a copy of + # pinpredict/.github at the exact SHA this workflow file came + # from. github.workflow_sha is the PR head SHA in self-CI and + # the @ref SHA when invoked from a downstream caller. This also + # sidesteps the @main bootstrap chicken-and-egg the first time + # the action lands. + - name: Checkout pinpredict/.github at workflow ref + uses: actions/checkout@v6 + with: + repository: pinpredict/.github + ref: ${{ github.workflow_sha }} + path: .pinpredict-github + + - uses: ./.pinpredict-github/actions/validate-reusable-inputs diff --git a/README.md b/README.md index 3b58f6c..527c08b 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Why `.github` and not a dedicated `github-actions` repo: `.github` is *the* GitH |---|---| | `discover-services` | Reads `.platform/services/*.yaml` and emits a docker matrix of services whose docker-relevant files changed since their last `image//*` tag. Also emits `charts_changed`. | | `validate-platform-service` | Pre-merge static + render check for added/modified `.platform/services/*.yaml`. Renders each via `charts/service-template` for every env in `environments[]` with all `renderXxx` flags forced on; verifies `repositories.chart` resolves to a real `charts//Chart.yaml`. Closes the gap from platform-gitops#544 — every dis-opticodds-props-streamer failure mode would have failed CI here. | +| `validate-reusable-inputs` | Cross-repo input validation for callers of `pinpredict/.github` reusable workflows. Diffs every `with:` block against the referenced workflow's `on.workflow_call.inputs` map; fails on unknown keys or missing-required keys. Closes the gap left by stock `actionlint`, which can't fetch remote reusable workflows (platform-gitops#1045). Runs automatically as a sibling job in `actionlint.yml`, so any consumer that already `uses:` that reusable workflow inherits it. | | `setup-python-uv` | Install uv + a pinned Python version + (default-on) `uv sync`. | | `setup-node-pnpm` | corepack + setup-node@v4 with pnpm cache + (default-on) `pnpm install --frozen-lockfile`. Accepts a `pnpm-filter` input for workspace filtering. | | `setup-dotnet` | setup-dotnet@v5 with NuGet cache keyed on `**/*.csproj` + (default-off) `dotnet tool restore`. | diff --git a/actions/validate-reusable-inputs/action.yml b/actions/validate-reusable-inputs/action.yml new file mode 100644 index 0000000..a9e5ed7 --- /dev/null +++ b/actions/validate-reusable-inputs/action.yml @@ -0,0 +1,56 @@ +name: Validate reusable-workflow inputs +description: | + Pre-merge cross-repo input validation for callers of + `pinpredict/.github` reusable workflows. + + Closes the gap left by stock `actionlint` (PG#1045): actionlint + doesn't fetch remote reusable workflows, so a caller passing a + removed or renamed `with:` key to + `pinpredict/.github/.github/workflows/.yml@` only fails at + runtime as `startup_failure`. This action diffs every caller `with:` + block against the remote workflow's declared `on.workflow_call.inputs` + map and fails CI on: + + * unknown keys (typos, removed/renamed inputs) + * missing keys that the callee declares `required: true` + + Scope: reusable workflows under `pinpredict/.github/.github/workflows/`. + Composite actions under `pinpredict/.github/actions/` are NOT validated + here — actionlint already catches unknown inputs on those when the + action.yml is checked out locally. + +inputs: + workflows-glob: + description: | + Glob (relative to the repo root) of caller workflow files to scan. + Defaults to every yaml under `.github/workflows/`. + required: false + default: ".github/workflows/*.yml .github/workflows/*.yaml" + central-repo: + description: | + `owner/repo` of the reusable-workflow source. Only `uses:` lines + pointing at this repo are validated. + required: false + default: "pinpredict/.github" + github-token: + description: | + Token with `contents: read` on `central-repo`. Defaults to the + job's `GITHUB_TOKEN`, which works when this action runs inside a + reusable workflow hosted in `central-repo` (same-repo read). + Override with a PAT or GitHub App token for cross-repo runs where + the default token lacks access. + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Validate + shell: bash + env: + WORKFLOWS_GLOB: ${{ inputs.workflows-glob }} + CENTRAL_REPO: ${{ inputs.central-repo }} + GH_TOKEN: ${{ inputs.github-token }} + run: | + set -euo pipefail + python3 "${GITHUB_ACTION_PATH}/validate_reusable_inputs.py" diff --git a/actions/validate-reusable-inputs/validate_reusable_inputs.py b/actions/validate-reusable-inputs/validate_reusable_inputs.py new file mode 100644 index 0000000..00a724d --- /dev/null +++ b/actions/validate-reusable-inputs/validate_reusable_inputs.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Cross-repo input validation for callers of pinpredict/.github reusable workflows. + +See ./action.yml for the rationale and contract. This script is invoked by the +composite action with WORKFLOWS_GLOB, CENTRAL_REPO, and GH_TOKEN in the env. +""" + +from __future__ import annotations + +import glob +import json +import os +import re +import subprocess +import sys +from functools import lru_cache + +import yaml + +CENTRAL_REPO = os.environ["CENTRAL_REPO"] +WORKFLOWS_GLOB = os.environ["WORKFLOWS_GLOB"] + +# `pinpredict/.github/.github/workflows/.yml@` +USES_RE = re.compile( + rf"^{re.escape(CENTRAL_REPO)}/\.github/workflows/(?P[^@]+)@(?P.+)$" +) + + +@lru_cache(maxsize=None) +def fetch_workflow_inputs(path: str, ref: str) -> dict[str, dict]: + """Return the `on.workflow_call.inputs` map for a remote workflow file. + + Empty dict if the workflow declares no inputs. Raises on fetch / parse failure + so the action fails loudly rather than silently passing. + """ + api = f"repos/{CENTRAL_REPO}/contents/{path}?ref={ref}" + raw = subprocess.check_output( + ["gh", "api", "-H", "Accept: application/vnd.github.raw", api], + text=True, + ) + doc = yaml.safe_load(raw) or {} + # `on:` parses as the literal True in PyYAML 1.1 mode; safe_load is 1.1. + on_block = doc.get(True, doc.get("on", {})) or {} + if not isinstance(on_block, dict): + return {} + wc = on_block.get("workflow_call") or {} + inputs = (wc.get("inputs") if isinstance(wc, dict) else None) or {} + return inputs if isinstance(inputs, dict) else {} + + +def iter_jobs(doc: dict): + jobs = doc.get("jobs") or {} + if not isinstance(jobs, dict): + return + for name, job in jobs.items(): + if isinstance(job, dict): + yield name, job + + +def check_workflow(path: str) -> list[str]: + """Return a list of human-readable violations for one caller workflow file.""" + with open(path) as f: + doc = yaml.safe_load(f) or {} + if not isinstance(doc, dict): + return [] + + violations: list[str] = [] + for job_name, job in iter_jobs(doc): + uses = job.get("uses") + if not isinstance(uses, str): + continue + m = USES_RE.match(uses.strip()) + if not m: + continue + + wf_file = f".github/workflows/{m.group('file')}" + ref = m.group("ref") + caller_with = job.get("with") or {} + if not isinstance(caller_with, dict): + caller_with = {} + + try: + declared = fetch_workflow_inputs(wf_file, ref) + except subprocess.CalledProcessError as e: + violations.append( + f"{path}: job `{job_name}` references {CENTRAL_REPO}/{wf_file}@{ref} " + f"but the workflow could not be fetched (gh api exit {e.returncode}). " + "Check the ref exists and GH_TOKEN has `contents: read`." + ) + continue + + declared_keys = set(declared.keys()) + caller_keys = set(caller_with.keys()) + + unknown = sorted(caller_keys - declared_keys) + for key in unknown: + suggestion = "" + if declared_keys: + close = sorted(declared_keys, key=lambda k: _similar(k, key), reverse=True) + suggestion = f" (did you mean `{close[0]}`?)" + violations.append( + f"{path}: job `{job_name}` passes unknown input `{key}` to " + f"{CENTRAL_REPO}/{wf_file}@{ref}{suggestion}" + ) + + required_missing = sorted( + key + for key, spec in declared.items() + if isinstance(spec, dict) + and spec.get("required") is True + and spec.get("default") is None + and key not in caller_keys + ) + for key in required_missing: + violations.append( + f"{path}: job `{job_name}` is missing required input `{key}` for " + f"{CENTRAL_REPO}/{wf_file}@{ref}" + ) + + return violations + + +def _similar(a: str, b: str) -> float: + """Cheap similarity score for did-you-mean hints (no stdlib import bloat).""" + a, b = a.lower(), b.lower() + if not a or not b: + return 0.0 + shared = len(set(a) & set(b)) + return shared / max(len(set(a) | set(b)), 1) + + +def main() -> int: + paths: list[str] = [] + for pattern in WORKFLOWS_GLOB.split(): + paths.extend(sorted(glob.glob(pattern))) + if not paths: + print(f"validate-reusable-inputs: no files matched `{WORKFLOWS_GLOB}`") + return 0 + + all_violations: list[str] = [] + for path in paths: + all_violations.extend(check_workflow(path)) + + if all_violations: + print(f"validate-reusable-inputs: {len(all_violations)} violation(s):") + for v in all_violations: + print(f" - {v}") + return 1 + + print( + f"validate-reusable-inputs: {len(paths)} workflow file(s) scanned, " + f"all caller `with:` blocks match {CENTRAL_REPO}." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())