From d1c87020b1a957284b90303991deeee43a4d2aa6 Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Fri, 21 Aug 2026 14:52:02 +0000 Subject: [PATCH 1/2] fix: use single quotes in a workflow expression, and lint for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first deploy after the workflow_run migration failed before any job started — no logs, no annotations, no failing step, because GitHub rejected the workflow file itself: publish-gh-pages.yml:146: got unexpected character '"' while lexing expression ... only single quotes are available for string delimiter `${{ github.event.workflow_run.html_url || "manual dispatch" }}`, in the context-logging step. Expressions take single-quoted string literals only, and a single-quoted literal cannot be nested inside the single-quoted shell word it was written in. Fixed by taking every context value through `env:` instead of interpolating it into the script — which is the right shape anyway, since it also keeps arbitrary values out of the shell. The reason this reached main is worth more than the fix. Workflow expressions are validated only by GitHub: this passed YAML parsing, passed `bash -n` on every embedded script, and passed the workflow harness (which substitutes `${{ }}` away before running anything). Nothing local could see it, and the failure mode gives you no logs to read. So _lint.yml now runs actionlint, which reports this exact error and pinpoints the column. It was previously rejected for false-flagging job.workflow_repository / job.workflow_sha — its `job` context schema is stale, while those properties are real and publish-gh-pages.yml depends on them. Those two are suppressed by an -ignore pattern narrow enough to not hide anything else; with them suppressed the repo lints clean. Co-Authored-By: Claude Opus 5 --- .github/workflows/_lint.yml | 21 +++++++++++++++++++++ .github/workflows/publish-gh-pages.yml | 18 ++++++++++++++---- CLAUDE.md | 5 +++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 151a509..44aadff 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -13,3 +13,24 @@ jobs: - name: Run hooks on all files run: npx prek run --all-files --show-diff-on-failure + # Workflow EXPRESSIONS are validated only by GitHub, and a bad one rejects the + # whole workflow before any job starts — no logs, no annotations, no failing step. + # That is how `|| "manual dispatch"` (expressions take single-quoted literals only) + # took the site's deploy down after it had passed YAML parsing, `bash -n`, and the + # workflow harness. actionlint catches exactly that class. + # + # Two errors are suppressed: actionlint's `job` context schema is stale and does not + # know `job.workflow_repository` / `job.workflow_sha`, both of which are real and + # documented, and which publish-gh-pages.yml depends on to version-match assemble.mjs. + - name: Lint workflow files + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + ver=$(gh api repos/rhysd/actionlint/releases/latest --jq .tag_name | tr -d v) + gh release download "v$ver" --repo rhysd/actionlint \ + -p "actionlint_${ver}_linux_amd64.tar.gz" -O "$RUNNER_TEMP/al.tgz" + tar xzf "$RUNNER_TEMP/al.tgz" --no-same-owner -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" -color \ + -ignore 'property "workflow_(repository|sha)" is not defined' + diff --git a/.github/workflows/publish-gh-pages.yml b/.github/workflows/publish-gh-pages.yml index 68ce45a..27a7196 100644 --- a/.github/workflows/publish-gh-pages.yml +++ b/.github/workflows/publish-gh-pages.yml @@ -132,17 +132,27 @@ jobs: # BUILT_BRANCH vs github.ref is the one thing a post-mortem here always trips over, # so state both up front rather than leaving someone to infer it from a cache miss. + # + # Every context value comes through `env:`, never interpolated into the script. + # That keeps arbitrary values out of the shell, and it avoids nesting quotes: an + # expression's string literals must be SINGLE-quoted, which cannot be written + # inside a single-quoted shell word. - name: Create staging dirs + env: + EVENT: ${{ github.event_name }} + GH_REF: ${{ github.ref }} + GH_SHA: ${{ github.sha }} + TRIGGER_URL: ${{ github.event.workflow_run.html_url }} run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/site" "$RUNNER_TEMP/gather" printf '%-16s %s\n' \ - 'event' '${{ github.event_name }}' \ + 'event' "$EVENT" \ 'built branch' "$BUILT_BRANCH" \ 'default' "$DEFAULT" \ - 'github.ref' '${{ github.ref }} (always the default branch under workflow_run)' \ - 'github.sha' '${{ github.sha }} (→ pages_build_version)' \ - 'triggered by' '${{ github.event.workflow_run.html_url || "manual dispatch" }}' + 'github.ref' "$GH_REF (always the default branch under workflow_run)" \ + 'github.sha' "$GH_SHA (→ pages_build_version)" \ + 'triggered by' "${TRIGGER_URL:-manual dispatch}" # The live Pages base URL, from the Pages API — respects a custom domain # (CNAME); falls back to the conventional github.io URL if the API call diff --git a/CLAUDE.md b/CLAUDE.md index 740fc21..8c54a83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,8 @@ consumer's repo stays checked out at the root so `assemble.mjs`'s `git tag` list so a composite action could only be pinned to a literal tag — the `job` context is the only way to self-reference at the running ref.) Note: actionlint's `job`-context schema is stale and false-flags `job.workflow_sha`/`job.workflow_repository`; the -GitHub docs confirm both. This repo lints with biome, not actionlint. +GitHub docs confirm both, so `_lint.yml` runs actionlint with exactly those two +suppressed (`-ignore 'property "workflow_(repository|sha)" is not defined'`). ### BASE_URL must be set before `myst build` ```yaml @@ -256,7 +257,7 @@ PRs always, fork PRs only when the SHA carries that status. ```bash npm test # run the unit tests -npm run test:workflows # run publish.yml's gather steps against a mock gh (needs uv) +npm run test:workflows # gather steps against a mock gh + workflow shape asserts (needs uv) npm run docs # build docs (same command CI uses) npm run docs-dev # live-preview docs with the plugin loaded from local plugins/ ``` From 9ca0829ae13a058fa5f942cc225cd7a81760a70c Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Fri, 21 Aug 2026 14:54:43 +0000 Subject: [PATCH 2/2] fix: address the two shellcheck findings actionlint surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI runner has shellcheck installed, so actionlint runs it over every embedded script — which my local run had disabled, so I missed both. SC2002: `$(cat file | head -1)` is a pointless subprocess; `head -1 file`. SC2015: `[ -n "$stable_src" ] && ln -s … || true` is not if-then-else. It swallows a FAILED symlink as well as the intended empty-stable_src case, and a missing stable/ alias is silent damage — the root redirect targets that constant URL, and cross-project intersphinx points at it. Now an explicit `if`, so a real `ln` failure fails the deploy. This one predates the migration; actionlint found it on its first run. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish-gh-pages.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-gh-pages.yml b/.github/workflows/publish-gh-pages.yml index 27a7196..f1d34bb 100644 --- a/.github/workflows/publish-gh-pages.yml +++ b/.github/workflows/publish-gh-pages.yml @@ -262,7 +262,7 @@ jobs: while IFS=$'\t' read -r tag dest created size assetid decision; do [ "$dest" = "-" ] && continue if [ -f "$failed/$assetid" ]; then - echo "::warning::release $tag: docs.zip download failed — skipping. $(cat "$failed/$assetid.err" 2>/dev/null | head -1)" + echo "::warning::release $tag: docs.zip download failed — skipping. $(head -1 "$failed/$assetid.err" 2>/dev/null)" elif [ -s "$relcache/$assetid.zip" ]; then cp "$relcache/$assetid.zip" "$RUNNER_TEMP/gather/$dest.zip" else @@ -533,7 +533,11 @@ jobs: set -euo pipefail stable_src=$(node "$GITHUB_WORKSPACE/.mvs/assemble/assemble.mjs" generate \ --site-dir "$RUNNER_TEMP/site" --base-url "$PAGES_URL" --required "$DEFAULT") - [ -n "$stable_src" ] && ln -s "$stable_src" "$RUNNER_TEMP/site/stable" || true + # An `&& ln … || true` one-liner here would also swallow a FAILED symlink, not + # just the empty-stable_src case — and a missing stable/ alias is silent damage. + if [ -n "$stable_src" ]; then + ln -s "$stable_src" "$RUNNER_TEMP/site/stable" + fi - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5