Files
cereale/.github/workflows/pages.yml
T
Claude 6adf26964e 🌾 ci: only deploy Pages when docs/ actually changed
Gating the deploy on CI success (#12) cost the paths filter, because
workflow_run cannot carry one: every green CI run on main redeployed the site,
including the README-only merge in #13 that touched no published byte.

The question is now asked in a `changes` job whose answer gates build and
deploy. It compares docs/ against the commit behind the newest *successful*
github-pages deployment — what is actually live — rather than against HEAD^.
That distinction is the whole point:

- a push carrying several commits may hide the docs change in any of them,
  and HEAD^ only sees the last one;
- if the previous deploy failed, the site is a version further behind than
  the previous commit suggests, and HEAD^ would skip the deploy that fixes it.

Deploys anyway, deliberately, when there is no successful deployment to
compare against, when the live commit is missing from history (force push),
and on workflow_dispatch — manual dispatch means "publish now", not "check
whether I need to".

Both checkouts now pin ref to github.event.workflow_run.head_sha. For
workflow_run the default checkout is the branch tip, not the commit CI
validated, so two pushes in quick succession could publish the newer tree
under the older one's green tick. Empty on workflow_dispatch, where the
dispatched ref is already what we want.

Verified by extracting the step's script from the YAML and running it against
this repo's real history with the API stubbed at curl: docs-identical head
skips; failed-newest-deploy deploys; missing/absent/dispatch all deploy; a
genuine docs change deploys and lists the files. actionlint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-20 14:36:58 +00:00

157 lines
6.2 KiB
YAML

name: Deploy Pages
# Publishes docs/ to GitHub Pages through Actions rather than by serving the branch
# directly, so the page is rebuilt from src/ and checked before it goes live instead
# of after.
#
# Triggered by CI completing on main rather than by the push itself, so a deploy
# implies the full suite passed — type-check, lint, tests, build, entry points, docs.
# A push that breaks a test turns main red and never reaches Pages. workflow_dispatch
# stays as the manual escape hatch, and the deploy job refuses any ref that is not main.
#
# workflow_run cannot carry a `paths:` filter the way `push:` can, so the docs-changed
# question is asked in a job instead — see the `changes` job below.
#
# REQUIRES A ONE-TIME SETTING. Settings → Pages → Build and deployment → Source must
# be "GitHub Actions", not "Deploy from a branch". Until it is, the first run fails —
# in the build job at configure-pages if Pages was never enabled, or in the deploy
# job with "Resource not accessible by integration" if Pages still serves a branch.
# Either way the workflow is correct; the repository setting is what needs to move.
# The switch cannot be made from here: it needs administration:write, which
# GITHUB_TOKEN is not. It is reversible — setting Source back to a branch restores
# the old behaviour and this workflow simply stops being able to deploy.
on:
workflow_run:
workflows: [ CI ]
types: [ completed ]
branches: [ main ]
workflow_dispatch:
# Never cancel a deploy in flight: a half-published site is worse than a stale one.
# Queue instead, so the last push wins without interrupting the one already going out.
concurrency:
group: pages
cancel-in-progress: false
jobs:
changes:
name: Did docs change?
runs-on: ubuntu-latest
# workflow_run fires on failure too — deploying is the one thing that must not.
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
permissions:
contents: read
deployments: read
outputs:
docs: ${{ steps.check.outputs.docs }}
steps:
- uses: actions/checkout@v4
with:
# For workflow_run the default checkout is the branch tip, which is not
# necessarily the commit CI just validated — two pushes in quick succession
# would deploy the newer one under the older one's green tick. Empty on
# workflow_dispatch, where the dispatched ref is what we want.
ref: ${{ github.event.workflow_run.head_sha }}
# Deep enough to reach whatever commit is currently live.
fetch-depth: 0
- id: check
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — deploying whatever the state of docs/ is."
echo "docs=true" >> "$GITHUB_OUTPUT"
exit 0
fi
api() { curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$@"; }
BASE="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY"
# What is actually live: the commit behind the newest *successful* Pages
# deployment. Comparing against that, rather than against HEAD^, is what keeps
# this correct when a push carries several commits (the change may be in any of
# them) and when the last deploy failed (the site is then a version further
# behind than the previous commit suggests).
DEPLOYMENTS=$(api "$BASE/deployments?environment=github-pages&per_page=10")
LIVE=""
for id in $(echo "$DEPLOYMENTS" | jq -r '.[].id'); do
state=$(api "$BASE/deployments/$id/statuses?per_page=1" | jq -r '.[0].state // empty')
if [ "$state" = "success" ]; then
LIVE=$(echo "$DEPLOYMENTS" | jq -r --argjson id "$id" '.[] | select(.id == $id) | .sha')
break
fi
done
if [ -z "$LIVE" ]; then
echo "No successful github-pages deployment to compare against — deploying."
echo "docs=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "${LIVE}^{commit}" 2>/dev/null; then
echo "Live commit $LIVE is not in this history (force push, or rebuilt branch) — deploying."
echo "docs=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Live on Pages: $LIVE"
echo "This commit: $GITHUB_SHA"
if git diff --quiet "$LIVE" HEAD -- docs/; then
echo "docs/ is byte-identical to what is already published — nothing to deploy."
echo "docs=false" >> "$GITHUB_OUTPUT"
else
echo "docs/ changed:"
git diff --name-only "$LIVE" HEAD -- docs/ | sed 's/^/ /'
echo "docs=true" >> "$GITHUB_OUTPUT"
fi
build:
name: Build and check
needs: changes
if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
# This job runs third-party code (npm postinstall scripts, the build toolchain),
# so it gets read-only. The Pages/OIDC grants live on the deploy job alone.
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: The committed page is in sync with src/
# Rebuilds from src/ and fails on any difference, new untracked files included.
# Same script CI runs, so the two workflows cannot drift apart.
run: npm run check:docs-sync
- name: The page loads nothing from the network
run: npm run check:docs
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: ./docs
deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
# workflow_dispatch can be pointed at any branch; production only ever serves main.
if: github.ref == 'refs/heads/main'
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5