Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions .github/workflows/actionlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/*` 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/<x>/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`. |
Expand Down
56 changes: 56 additions & 0 deletions actions/validate-reusable-inputs/action.yml
Original file line number Diff line number Diff line change
@@ -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/<x>.yml@<ref>` 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"
158 changes: 158 additions & 0 deletions actions/validate-reusable-inputs/validate_reusable_inputs.py
Original file line number Diff line number Diff line change
@@ -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/<file>.yml@<ref>`
USES_RE = re.compile(
rf"^{re.escape(CENTRAL_REPO)}/\.github/workflows/(?P<file>[^@]+)@(?P<ref>.+)$"
)


@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())