www.yml: Slack range floor only counts runs that actually deployed #243
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@v4 | |
| with: | |
| ssh-key: ${{ secrets.WWW_DEPLOY_KEY }} | |
| - uses: pnpm/action-setup@v4 | |
| with: | |
| version: 9 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| cache: 'pnpm' | |
| cache-dependency-path: www/pnpm-lock.yaml | |
| - 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: | | |
| # Wait briefly for GH Pages to flip the live tree (it polls ghp | |
| # branch every ~10s). If chunks referenced by the just-deployed | |
| # `index.html` 404 against ctbk.dev, fail loudly so the deploy | |
| # is visibly broken — we hit exactly this when `clean-exclude` | |
| # silently dropped chunks (commit ea82f3c0). | |
| for i in 1 2 3 4 5 6; do | |
| sleep 15 | |
| html=$(curl -sf https://ctbk.dev/ || true) | |
| [ -n "$html" ] && break | |
| done | |
| chunks=$(echo "$html" | grep -oE 'assets/[A-Za-z0-9._/-]+\.(js|css)' | sort -u) | |
| [ -z "$chunks" ] && { echo 'no chunks parsed from live index.html — aborting'; exit 1; } | |
| fail=0 | |
| for c in $chunks; do | |
| code=$(curl -s -o /dev/null -w '%{http_code}' "https://ctbk.dev/$c") | |
| echo "$code https://ctbk.dev/$c" | |
| [ "$code" = "200" ] || fail=1 | |
| done | |
| [ "$fail" = "0" ] || { echo 'one or more chunks 404 — deploy is broken'; 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: | | |
| python - <<'PY' | |
| import json, os, urllib.request | |
| slack_token = os.environ['SLACK_BOT_TOKEN'] | |
| 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. | |
| text = ( | |
| f":rocket: *ctbk.dev deployed* — <{repo}/commit/{sha}|`{short}`> {subject}\n" | |
| f"<https://ctbk.dev|ctbk.dev> · <{run_url}|run>" | |
| ) | |
| else: | |
| # Multi-commit deploy: show range + count, defer per-commit detail to a thread reply. | |
| short_prev = prev_sha[:7] | |
| compare_url = f"{repo}/compare/{prev_sha}...{sha}" | |
| 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>" | |
| ) | |
| base_payload = { | |
| 'channel': 'C0B5MKF28NP', | |
| 'username': 'ctbk-bot', | |
| 'icon_emoji': ':bike:', | |
| 'unfurl_links': False, | |
| } | |
| def post(extra): | |
| payload = {**base_payload, **extra} | |
| req = urllib.request.Request( | |
| 'https://slack.com/api/chat.postMessage', | |
| data=json.dumps(payload).encode(), | |
| headers={ | |
| 'Authorization': f'Bearer {slack_token}', | |
| 'Content-Type': 'application/json; charset=utf-8', | |
| }, | |
| ) | |
| with urllib.request.urlopen(req) as r: | |
| body = json.load(r) | |
| if not body.get('ok'): | |
| raise SystemExit(f"slack postMessage failed: {body}") | |
| return body | |
| op = post({'text': text}) | |
| # Per-commit bullets in a thread reply when there's more than one. | |
| if n > 1: | |
| lines = [] | |
| for c in commits: | |
| c_sha = c['sha'] | |
| c_subject = (c['commit']['message'] or '').splitlines()[0] | |
| lines.append(f"• <{repo}/commit/{c_sha}|`{c_sha[:7]}`> {c_subject}") | |
| post({'thread_ts': op['ts'], 'text': '\n'.join(lines)}) | |
| PY |