Skip to content

www.yml: regenerate og:image mosaic in CI #263

www.yml: regenerate og:image mosaic in CI

www.yml: regenerate og:image mosaic in CI #263

Workflow file for this run

name: GitHub Pages
on:
push:
branches:
- www
- scrns-playwright-test
workflow_dispatch:
jobs:
build:
name: Build + deploy www branch to ctbk.dev via GitHub Pages
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ssh-key: ${{ secrets.WWW_DEPLOY_KEY }}
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v5
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: www/pnpm-lock.yaml
# uv for the Slack-deploy-notification step (`thrds`).
- uses: astral-sh/setup-uv@v6
- name: Install
working-directory: www
run: pnpm install
- name: Typecheck
working-directory: www
run: pnpm tc
- name: Install Playwright browsers
working-directory: www
run: pnpm exec playwright install --with-deps chromium
- name: e2e tests (gates the deploy)
working-directory: www
env:
CI: '1'
run: pnpm test --reporter=list
- name: Build
working-directory: www
run: |
pnpm build
# Copy index.html to 404.html for GHP SPA routing
cp dist/index.html dist/404.html
- name: Generate screenshots (Playwright in Docker)
working-directory: www
run: |
docker build --platform linux/amd64 -f Dockerfile.screenshots -t ctbk-screenshots .
docker run --platform linux/amd64 --name ctbk-screenshots-run ctbk-screenshots
docker cp ctbk-screenshots-run:/app/public/screenshots/. public/screenshots/
docker rm ctbk-screenshots-run
- name: Upload screenshots as artifact
if: github.ref != 'refs/heads/www'
uses: actions/upload-artifact@v4
with:
name: screenshots
path: www/public/screenshots/
- name: Compose og:image mosaic
working-directory: www
# Recomposes `ctbk-og-mosaic.jpg` from the just-regenerated
# screenshots (map + 2 plots). Excluded from the change-gate below
# (like `ctbk-stations`) because the map render varies per run —
# gating on it would re-trigger forever. It still ships whenever a
# PLOT screenshot changes (monthly data updates), riding the same
# commit.
run: bash scripts/compose-og.sh
- name: Push + re-run, if screenshots changed
if: github.ref == 'refs/heads/www'
id: screenshots
run: |
if git diff --name-only -- www/public/screenshots | grep -v -e ctbk-stations -e ctbk-og-mosaic; then
git add www/public/screenshots
git config --global user.name "GitHub Actions"
git config --global user.email "github@actions"
git commit -m "Update screenshots"
git pull origin www
echo "Pushing www"
git push origin HEAD:www
echo "Updating main branch"
git fetch --unshallow origin main
git pull origin main
git push origin HEAD:main
echo "REGENERATED=true" >> $GITHUB_OUTPUT
fi
- name: Deploy to GH Pages
id: deploy
uses: JamesIves/github-pages-deploy-action@v4
if: github.ref == 'refs/heads/www' && steps.screenshots.outputs.REGENERATED != 'true'
with:
branch: ghp
folder: www/dist
# NOTE: `clean-exclude` was tried here to preserve stale chunks
# for in-flight tabs across deploys, but JamesIves passes it as
# `rsync --exclude` which excludes from BOTH sides — i.e. NEW
# chunks weren't deployed either, breaking the site. Removed.
# If we want stale-tab survival, do it via a post-deploy step
# that copies old chunks back into ghp explicitly.
- name: Post-deploy smoke check
id: smoke
if: steps.deploy.outcome == 'success'
working-directory: www
run: |
# Poll until the live `index.html` AND every chunk it
# references all return 200, up to 5 min. GH Pages can flip the
# root and the chunk tree out of sync — old `index.html` may
# linger briefly with stale chunk refs, or new `index.html` may
# serve before its new chunks propagate. Both look like a
# 404'd chunk to a one-shot check. Fail loudly only if the
# mismatch is still present after the deadline (this catches
# the `clean-exclude`-dropped-chunks bug from `ea82f3c0`).
deadline=$((SECONDS + 300))
chunks=
while [ $SECONDS -lt $deadline ]; do
sleep 15
html=$(curl -sf https://ctbk.dev/ || true)
[ -z "$html" ] && continue
chunks=$(echo "$html" | grep -oE 'assets/[A-Za-z0-9._/-]+\.(js|css)' | sort -u)
[ -z "$chunks" ] && continue
fail=0
for c in $chunks; do
code=$(curl -s -o /dev/null -w '%{http_code}' "https://ctbk.dev/$c")
[ "$code" = "200" ] || { fail=1; break; }
done
if [ "$fail" = "0" ]; then
echo "smoke OK: $(echo $chunks | wc -w) chunk(s) all 200"
exit 0
fi
done
echo 'smoke check timed out (5min); last observed chunk status:'
for c in $chunks; do
code=$(curl -s -o /dev/null -w '%{http_code}' "https://ctbk.dev/$c")
echo " $code https://ctbk.dev/$c"
done
exit 1
- name: Notify Slack — site deployed
if: github.ref == 'refs/heads/www' && steps.deploy.outcome == 'success' && steps.smoke.outcome == 'success'
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.sha }}
RUN_ID: ${{ github.run_id }}
MSG: ${{ github.event.head_commit.message }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
REPO_SLUG: ${{ github.repository }}
run: |
uv run --no-project --with 'thrds==0.4.0' --python 3.12 python - <<'PY'
import json, os, urllib.request
from thrds import SlackClient, Thread
gh_token = os.environ['GITHUB_TOKEN']
sha = os.environ['SHA']
run_id = os.environ['RUN_ID']
short = sha[:7]
subject = (os.environ.get('MSG') or '').splitlines()[0] if os.environ.get('MSG') else '(no message)'
repo = os.environ['REPO_URL']
run_url = os.environ['RUN_URL']
repo_slug = os.environ['REPO_SLUG']
def gh(path):
req = urllib.request.Request(f'https://api.github.com{path}', headers={
'Authorization': f'Bearer {gh_token}',
'Accept': 'application/vnd.github+json',
})
with urllib.request.urlopen(req) as r:
return json.load(r)
# Range floor = last run that *actually deployed* (not just
# passed). Failed deploys stack between successes; the
# screenshot-regen branch of this workflow finishes successfully
# but skips the Deploy step. Querying `status=success` alone
# would pick up those no-op runs, so we additionally check each
# run's `Deploy to GH Pages` step conclusion.
prev_sha = None
runs = gh(f'/repos/{repo_slug}/actions/workflows/www.yml/runs?status=success&branch=www&per_page=10')
for r in runs.get('workflow_runs', []):
if str(r['id']) == run_id:
continue # belt-and-suspenders; shouldn't appear
jobs = gh(f'/repos/{repo_slug}/actions/runs/{r["id"]}/jobs')
deployed = any(
s.get('name') == 'Deploy to GH Pages' and s.get('conclusion') == 'success'
for j in jobs.get('jobs', [])
for s in j.get('steps', [])
)
if deployed:
prev_sha = r['head_sha']
break
commits = []
if prev_sha and prev_sha != sha:
try:
commits = gh(f'/repos/{repo_slug}/compare/{prev_sha}...{sha}').get('commits', [])
except Exception as e:
# Force-push or rebase can make `prev_sha` unreachable; degrade gracefully.
print(f'compare {prev_sha}..{sha} failed: {e}; falling back to single-commit shape')
n = len(commits)
if n <= 1:
# Single commit (or unknown range): show that commit's subject inline.
op_text = (
f":rocket: *ctbk.dev deployed* — <{repo}/commit/{sha}|`{short}`> {subject}\n"
f"<https://ctbk.dev|ctbk.dev> · <{run_url}|run>"
)
messages = [op_text]
else:
# Multi-commit deploy: OP shows range + count, thread reply has per-commit detail.
short_prev = prev_sha[:7]
compare_url = f"{repo}/compare/{prev_sha}...{sha}"
op_text = (
f":rocket: *ctbk.dev deployed* — <{compare_url}|`{short_prev}..{short}`> ({n} commits since last deploy)\n"
f"<https://ctbk.dev|ctbk.dev> · <{run_url}|run>"
)
bullets = '\n'.join(
f"• <{repo}/commit/{c['sha']}|`{c['sha'][:7]}`> {(c['commit']['message'] or '').splitlines()[0]}"
for c in commits
)
messages = [op_text, bullets]
# `Thread.sync` posts `messages[0]` as the thread OP, the rest
# as replies — no manual `thread_ts` plumbing.
SlackClient(
token=os.environ['SLACK_BOT_TOKEN'],
channel='C0B5MKF28NP',
username='ctbk-bot',
icon_emoji=':bike:',
).sync(Thread(messages=messages))
PY