/health: cron-cached snapshot + pending grace + min-cover timeline bars #261
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: 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 ctbk-stations; 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 |