www: regen .dockerignore — whitelist FeedHealth.tsx + the Smg* …
#325
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: Deploy ctbk.dev | |
| on: | |
| push: | |
| branches: | |
| - www | |
| - dev | |
| - scrns-playwright-test | |
| workflow_dispatch: | |
| jobs: | |
| build: | |
| name: Build + deploy www branch to ctbk.dev (CF Workers Assets) | |
| 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: 22 # wrangler ≥4.110 requires ≥22 | |
| cache: 'pnpm' | |
| cache-dependency-path: www/pnpm-lock.yaml | |
| # uv for the Slack-deploy-notification step (`thrds`). | |
| - uses: astral-sh/setup-uv@v7 | |
| - 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 | |
| # ─── Screenshots: input-gated regen, HR S3 home | |
| # (specs/www-screenshots-s3-hr.md). Regenerate exactly when a | |
| # declared dep changed since the provenance recorded in the S3 | |
| # manifest (`s3://ctbk/screenshots/.deps.json`), BEFORE the build | |
| # consumes them. Nothing screenshot-related is git-tracked: the | |
| # old pointer-commit flow triggered a second, content-identical | |
| # deploy and raced concurrent pushes to `www`/`main`. | |
| - name: Screenshots dep-check | |
| id: scrgate | |
| if: github.ref != 'refs/heads/dev' | |
| run: | | |
| data_md5=$(awk '/md5:/{print $3; exit}' s3/ctbk/stations/ymdgtb.dvc) | |
| www_tree=$(git ls-files -s -- www ':!www/public/screenshots' | git hash-object --stdin) | |
| echo "data_md5=$data_md5" >> $GITHUB_OUTPUT | |
| echo "www_tree=$www_tree" >> $GITHUB_OUTPUT | |
| deps=$(curl -sf https://ctbk.s3.amazonaws.com/screenshots/.deps.json || echo '{}') | |
| rec_data=$(echo "$deps" | jq -r '.ymdgtb_md5 // "none"') | |
| rec_tree=$(echo "$deps" | jq -r '.www_tree // "none"') | |
| if [ "$data_md5" != "$rec_data" ] || [ "$www_tree" != "$rec_tree" ]; then | |
| echo "stale=true" >> $GITHUB_OUTPUT | |
| echo "stale: data $rec_data -> $data_md5 | tree $rec_tree -> $www_tree" | |
| else | |
| echo "stale=false" >> $GITHUB_OUTPUT | |
| echo "fresh (data $data_md5, tree $www_tree)" | |
| fi | |
| - name: Regenerate screenshots (deps changed) | |
| if: steps.scrgate.outputs.stale == 'true' | |
| working-directory: www | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| AWS_DEFAULT_REGION: us-east-1 | |
| 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 | |
| # ubuntu-24.04 runner images dropped ImageMagick. | |
| command -v magick >/dev/null || command -v convert >/dev/null \ | |
| || sudo apt-get install -y -qq imagemagick | |
| bash scripts/compose-og.sh | |
| - name: Publish screenshots to S3 | |
| if: steps.scrgate.outputs.stale == 'true' && github.ref == 'refs/heads/www' | |
| working-directory: www | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| AWS_DEFAULT_REGION: us-east-1 | |
| run: | | |
| # Overwrite the HR keys + manifest in place — no git commit, so | |
| # this run's build (below) is the only deploy. | |
| uv run scripts/push-screenshots.py \ | |
| -d "${{ steps.scrgate.outputs.data_md5 }}" \ | |
| -t "${{ steps.scrgate.outputs.www_tree }}" | |
| - name: Upload screenshots as artifact | |
| if: steps.scrgate.outputs.stale == 'true' && github.ref != 'refs/heads/www' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: screenshots | |
| path: www/public/screenshots/ | |
| - name: Build | |
| working-directory: www | |
| run: pnpm build | |
| # Workers-Assets deploy (`specs/done/www-cfw-migration.md`): atomic | |
| # manifest-based upload serving ctbk.dev via the `ctbk-dev` worker's | |
| # custom domain (attached once in the dashboard — deliberately NOT | |
| # declared in wrangler.jsonc, so this narrow-scope token needs no | |
| # zone permissions). | |
| # The live site self-reports its sha (`version.json`, written at | |
| # postbuild) — capture it BEFORE deploying to get the previous | |
| # deploy's sha for the Slack notify's compare range. Deterministic, | |
| # unlike the GH API runs-list archaeology it replaced (eventual | |
| # consistency mis-picked a week-old run as "previous deploy", | |
| # 2026-08-15). | |
| - name: Record previous deploy sha | |
| id: prev | |
| if: github.ref == 'refs/heads/www' || github.ref == 'refs/heads/dev' | |
| env: | |
| HOST: ${{ github.ref == 'refs/heads/dev' && 'dev.ctbk.dev' || 'ctbk.dev' }} | |
| run: | | |
| prev=$(curl -sf "https://$HOST/version.json" | jq -r '.sha // empty' || true) | |
| echo "sha=$prev" >> $GITHUB_OUTPUT | |
| echo "previous deploy: ${prev:-unknown (no version.json yet)}" | |
| - name: Deploy to Cloudflare Workers (ctbk-dev) | |
| id: deploy | |
| if: github.ref == 'refs/heads/www' || github.ref == 'refs/heads/dev' | |
| working-directory: www | |
| env: | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| # `dev` branch → staging worker `ctbk-dev-dev` (dev.ctbk.dev) | |
| ENV_FLAG: ${{ github.ref == 'refs/heads/dev' && '--env dev' || '' }} | |
| run: pnpm exec wrangler deploy $ENV_FLAG | |
| - name: Post-deploy smoke check | |
| id: smoke | |
| if: steps.deploy.outcome == 'success' | |
| working-directory: www | |
| env: | |
| HOST: ${{ github.ref == 'refs/heads/dev' && 'dev.ctbk.dev' || 'ctbk.dev' }} | |
| run: | | |
| # One-shot-ish (3 tries): Workers-Assets deploys are atomic | |
| # (index + chunks flip together), unlike the GHP era's | |
| # out-of-sync windows that needed a 5-min polling loop. | |
| for i in 1 2 3; do | |
| sleep 10 | |
| live=$(curl -sf "https://$HOST/version.json" | jq -r '.sha // empty' || true) | |
| if [ "$live" != "${{ github.sha }}" ]; then | |
| echo " version.json sha: ${live:-none} != ${{ github.sha }}" | |
| continue | |
| fi | |
| html=$(curl -sf "https://$HOST/" || 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://$HOST/$c") | |
| [ "$code" = "200" ] || { fail=1; echo " $code $c"; break; } | |
| done | |
| if [ "$fail" = "0" ]; then | |
| echo "smoke OK: version.json matches, $(echo $chunks | wc -w) chunk(s) all 200" | |
| exit 0 | |
| fi | |
| done | |
| echo 'smoke check failed after 3 attempts' | |
| 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 }} | |
| PREV_SHA: ${{ steps.prev.outputs.sha }} | |
| 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, subprocess, urllib.request | |
| from thrds import SlackClient, Thread | |
| gh_token = os.environ['GITHUB_TOKEN'] | |
| sha = os.environ['SHA'] | |
| short = sha[:7] | |
| # `head_commit` is absent on workflow_dispatch triggers; fall back | |
| # to the checked-out commit's subject instead of "(no message)". | |
| msg = os.environ.get('MSG') or subprocess.run( | |
| ['git', 'log', '-1', '--format=%s'], capture_output=True, text=True, | |
| ).stdout | |
| subject = msg.splitlines()[0] if msg.strip() 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 = what the live site served just before this | |
| # deploy (its `version.json`, captured by the `prev` step). | |
| prev_sha = os.environ.get('PREV_SHA') or None | |
| 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 | |
| # Failed deploys were previously SILENT (the success notify simply | |
| # didn't fire) — e.g. 2026-08-14's two e2e-gated failures left the | |
| # site undeployable for ~5h with no ping. | |
| - name: Notify Slack — deploy failed | |
| if: failure() && github.ref == 'refs/heads/www' | |
| env: | |
| SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} | |
| SHA: ${{ github.sha }} | |
| 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 }} | |
| run: | | |
| uv run --no-project --with 'thrds==0.4.0' --python 3.12 python - <<'PY' | |
| import os, subprocess | |
| from thrds import SlackClient | |
| sha = os.environ['SHA'] | |
| msg = os.environ.get('MSG') or subprocess.run( | |
| ['git', 'log', '-1', '--format=%s'], capture_output=True, text=True, | |
| ).stdout | |
| subject = msg.splitlines()[0] if msg.strip() else '(no message)' | |
| SlackClient( | |
| token=os.environ['SLACK_BOT_TOKEN'], | |
| channel='C0B5MKF28NP', | |
| username='ctbk-bot', | |
| icon_emoji=':bike:', | |
| ).post( | |
| f":x: *ctbk.dev deploy FAILED* — <{os.environ['REPO_URL']}/commit/{sha}|`{sha[:7]}`> {subject}\n" | |
| f"<{os.environ['RUN_URL']}|logs>" | |
| ) | |
| PY |