From 78cb00fecc2c990ee95a17e15007e668edc905b2 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Sun, 13 Sep 2026 21:15:42 -0700 Subject: [PATCH] Add CI for the shared workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo has no tests and nothing to build, but a mistake here breaks all three sites at once, and a workflow that fails to load reports only `startup_failure` with no logs and nothing on the annotations endpoint. Finding one of those cost a five-cycle bisection. So the checks target the failure modes that actually happened: - **actionlint**, which validates expressions against real context types and runs shellcheck over every `run:` block. It earned its place before being installed: it flagged `github.job_workflow_sha` as undefined, which five production runs then confirmed resolves to an empty string — a pin that was not pinning anything. And the worst bugs in these workflows have been shell rather than YAML: a `|| true` that swallowed an exit code and emailed an error body as a link, and a heredoc that fed one line to the interpreter. - **`node --check`** on the shared script, which is fetched and run at runtime, so a syntax error in it surfaces as a failed notification. - **A declared-inputs check**, since a `workflow_call` input used but never declared parses fine and arrives empty — the same silent class as the pin bug. Verified it fails on an undeclared input and an undeclared secret. The comments say what actionlint does *not* catch, so nobody assumes more of it than it gives: `vars.X` in a called workflow and `runner.temp` in a `with:` block both lint clean and both cost a cycle. `.github/actionlint.yaml` suppresses one style-only shellcheck rule with the reason recorded. Everything that could be a real bug stays on, which is why this currently exits non-zero on the `job_workflow_sha` line — so the pin fix must land first, rather than CI going red on its first run for a pre-existing issue. --- .github/workflows/ci.yml | 111 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8e5095b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +# Validate the shared workflows before they reach three sites at once. +# +# This repo has no tests to run and nothing to build — it is workflows and one +# script. But a mistake here is worse than a mistake in any single site: every +# site calls these, so a bad change breaks all three, and the failure arrives as +# GitHub's `startup_failure` with no logs and nothing on the annotations +# endpoint. Finding one of those cost a five-cycle bisection once already. +# +# So the checks here are about the failure modes that actually happened. +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + # Named `ci` so the required-check context is `ci`, matching post-inbox. A + # reusable workflow's context would be `caller / called`; this job is inline, + # so it is the bare job id. + ci: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + + # actionlint validates expressions against the real context types and runs + # shellcheck over every `run:` block. Both have earned their place: + # + # - it flagged `github.job_workflow_sha` as undefined, which five + # production runs then confirmed resolves to an empty string — a pin + # that was not pinning anything + # - the worst bugs in these workflows have been shell, not YAML: a + # `|| true` that swallowed an exit code and emailed an error body as a + # link, and a heredoc that fed only its first line to the interpreter + # + # What it does NOT catch, so nobody assumes otherwise: semantic + # `workflow_call` rules. `vars.X` unavailable in a called workflow, and + # `runner.temp` in a `with:` block, both pass actionlint cleanly and both + # cost a bisection cycle. + - name: Lint the workflows + uses: docker://rhysd/actionlint:1.7.7 + with: + args: -color + + - uses: actions/setup-node@v4 + with: + node-version: '24' + + # The script is fetched and executed by the notify workflow at runtime, so + # a syntax error in it surfaces as a failed notification rather than + # anything resembling a parse error. + - name: Check the shared script parses + run: node --check .github/scripts/notify-author.mjs + + # A `workflow_call` input or secret that is used but never declared is + # accepted at parse time and arrives empty at runtime — the same silent + # class as the pin bug, so it is worth failing on rather than discovering + # in a live notification. + - name: Check every workflow_call input and secret is declared + run: | + set -euo pipefail + status=0 + for file in .github/workflows/*.yml; do + # Only reusable workflows have inputs to declare. Anchored, so a + # `workflow_call` mentioned in a comment does not count. + grep -qE '^ workflow_call:' "$file" || continue + + declared="$(sed -n '/^ workflow_call:/,/^jobs:/p' "$file" \ + | grep -oE '^ [a-zA-Z][a-zA-Z0-9_-]*:' \ + | tr -d ' :' | sort -u)" + + used="$(grep -oE '(inputs|secrets)\.[a-zA-Z][a-zA-Z0-9_-]*' "$file" \ + | cut -d. -f2 | sort -u)" + + for name in $used; do + if ! printf '%s\n' "$declared" | grep -qx "$name"; then + echo "$file: uses \`$name\` but never declares it under workflow_call" + status=1 + fi + done + done + if [ "$status" -eq 0 ]; then + echo "Every input and secret in use is declared." + fi + exit "$status" + + - name: Explain a failure + if: failure() + run: | + cat >> "$GITHUB_STEP_SUMMARY" <<'NOTE' + ## Shared workflow validation failed + + These workflows are called by every site, so a bad change here breaks + all of them at once — and a workflow that fails to load reports only + `startup_failure`, with no logs to read. + + Reproduce locally: + + ``` + brew install actionlint # or see rhysd/actionlint releases + actionlint + node --check .github/scripts/notify-author.mjs + ``` + + **What this does not check:** whether a context is actually populated + inside a called workflow. `vars.X` is unavailable there, and + `github.job_workflow_sha` does not exist at all — both pass linting + and arrive empty at runtime. Print a value before depending on it. + NOTE