From 902661368158980f1ffaaeb7c4c770676b0eb02a Mon Sep 17 00:00:00 2001 From: Blair Hamilton Date: Mon, 8 Jun 2026 07:59:23 -0400 Subject: [PATCH 1/2] feat(actionlint): cross-repo input validation for pinpredict/.github reusable workflows Closes platform-gitops#1045. Stock `actionlint` (wired in #944) doesn't fetch remote reusable workflows, so a caller passing a removed or renamed `with:` key to `pinpredict/.github/.github/workflows/.yml@` still surfaces only at runtime as `startup_failure`. New composite action `actions/validate-reusable-inputs/` scans the caller's workflow files for `uses: pinpredict/.github/...@ref` lines, fetches each referenced workflow at the pinned ref via `gh api`, and diffs the caller `with:` block against the declared `on.workflow_call.inputs` map. Fails on unknown keys (with a did-you-mean hint) or missing required keys. Wired as a sibling job in `actionlint.yml`, so every consumer that already `uses:` the central actionlint workflow (service-template's `ci.yml` and every repo inheriting it) inherits the gate automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/actionlint.yml | 23 ++- README.md | 1 + actions/validate-reusable-inputs/action.yml | 56 +++++++ .../validate_reusable_inputs.py | 158 ++++++++++++++++++ 4 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 actions/validate-reusable-inputs/action.yml create mode 100644 actions/validate-reusable-inputs/validate_reusable_inputs.py diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 5056e9a..16b2cd4 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,19 @@ 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: + - uses: actions/checkout@v6 + + - uses: pinpredict/.github/actions/validate-reusable-inputs@main 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()) From 73a31ca2447a0b7370d10535947c628f0e300758 Mon Sep 17 00:00:00 2001 From: Blair Hamilton Date: Mon, 8 Jun 2026 08:03:04 -0400 Subject: [PATCH 2/2] fix(actionlint): vendor pinpredict/.github at workflow ref for validate-reusable-inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-CI was failing because uses: pinpredict/.github/actions/...@main resolved before the action existed on main. ./ in a reusable workflow resolves against the *caller's* checkout, not this repo's, so a relative path doesn't fix it either. Check out pinpredict/.github at github.workflow_sha (the SHA of the workflow file itself — PR head in self-CI, the @ref SHA when invoked downstream) and reference the action locally. Removes the bootstrap chicken-and-egg and guarantees the action source matches the workflow file that's running it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/actionlint.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 16b2cd4..cf6298c 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -65,6 +65,21 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - 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@main + - uses: ./.pinpredict-github/actions/validate-reusable-inputs