From 3d2f5d3fe8e58a06c0206eeb6472c987b675dfe1 Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 24 Oct 2026 03:18:38 +0900 Subject: [PATCH 1/7] ci: take screenshots for new live sites weekly and propose them as a PR A new repo with a live site sat on the desk as a blank sheet until someone added its screenshot by hand. shots.yml syncs the current repo list, shoots only what is missing (or everything with 'retake all'), and opens a PR. --- .github/workflows/shots.yml | 87 +++++++++++++++++++++++++++++++++++++ README.md | 5 ++- scripts/shoot.ts | 40 +++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/shots.yml create mode 100644 scripts/shoot.ts diff --git a/.github/workflows/shots.yml b/.github/workflows/shots.yml new file mode 100644 index 0000000..b0f662a --- /dev/null +++ b/.github/workflows/shots.yml @@ -0,0 +1,87 @@ +name: shots + +# A new repo with a live site shows up on the desk as a blank sheet until it has a +# screenshot. Once a week this takes the missing ones and proposes them as a pull +# request. Run it by hand with "retake all" after the sites themselves change. +# +# The pull request is opened with the workflow token, and GitHub does not start +# other workflows for events that token causes, so CI does not run on it by +# itself. Close and reopen the pull request to run CI before merging. + +on: + schedule: + - cron: "41 1 * * 1" + workflow_dispatch: + inputs: + all: + description: Retake every screenshot, not only the missing ones. + type: boolean + default: false + +permissions: {} + +concurrency: + group: shots + cancel-in-progress: false + +jobs: + versions: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + playwright: ${{ steps.v.outputs.playwright }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: v + run: echo "playwright=$(node -p 'require("./package.json").devDependencies["@playwright/test"]')" >> "$GITHUB_OUTPUT" + + shoot: + needs: versions + runs-on: ubuntu-latest + timeout-minutes: 20 + container: + image: mcr.microsoft.com/playwright:v${{ needs.versions.outputs.playwright }}-noble + options: --user 1001 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + # The committed repo list can lag behind the org; read the current one. + - run: pnpm run sync + env: + GITHUB_TOKEN: ${{ github.token }} + - run: node scripts/shoot.ts ${{ inputs.all && '--all' || '' }} + - name: Propose the new screenshots + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + if [ -z "$(git status --porcelain -- src/assets/shots)" ]; then + echo "Every live site already has a screenshot." + exit 0 + fi + branch="shots/$RUN_ID" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add -- src/assets/shots + git commit -m "chore(shots): update project screenshots" + git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$branch" + { + echo "Screenshots taken by the shots workflow, run $RUN_ID." + echo + git diff --name-status HEAD~1 -- src/assets/shots | sed 's/^/ /' + echo + echo "CI does not start on pull requests opened by the workflow token. Close and reopen this pull request to run it before merging." + } > "$RUNNER_TEMP/body.md" + gh pr create --repo "$REPO" --head "$branch" --title "chore(shots): update project screenshots" --body-file "$RUNNER_TEMP/body.md" diff --git a/README.md b/README.md index b373412..680fc97 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,14 @@ Drag the desk to look around and pinch (or press `+` and `-`) to zoom. Break the - `scripts/sync.ts` pulls the org's public repos (archived repos and forks are left out), the live region of each README game, and the latest dev.to articles into `src/data/`. A source that fails keeps its previous file. - `src/data/groups.ts` decides which pile a repo sits in. A new repo that isn't filed there shows up on its own pile on the next build. - `src/lib/desk.ts` holds where each sheet sits on the desk. `src/scripts/desk.ts` is the camera: pan, zoom, the minimap and the live frame. It has no dependencies. -- `src/assets/shots/` holds a screenshot per project, named after the repo. +- `src/assets/shots/` holds a screenshot per project, named after the repo. `scripts/shoot.ts` takes the missing ones; `--all` retakes every one. ## CI - `ci.yml` runs on every pull request: Biome, actionlint, `astro check`, Vitest, a build, and Playwright against that build in Chromium, WebKit, a phone and with JavaScript off. - `deploy.yml` runs the same CI on freshly synced data on every push to main and once a day, then deploys to Pages. - `claude-review.yml` reviews pull requests. It needs a `CLAUDE_CODE_OAUTH_TOKEN` secret from `claude setup-token`. +- `shots.yml` takes screenshots for new live sites every Monday and proposes them as a pull request. Run it by hand with "retake all" after the sites change. - Dependabot proposes npm and Actions updates every Monday, grouped and held for three days after release. ## Develop @@ -38,4 +39,4 @@ Astro 7, plain CSS, Node 24 or newer. 1. Add its name to a group in `src/data/groups.ts`. 2. Give it a spot in `src/lib/desk.ts`. -3. Drop a 1280×800 screenshot at `src/assets/shots/.png`. +3. Run `node scripts/shoot.ts`, or wait for the weekly shots workflow, to get its screenshot at `src/assets/shots/.png`. diff --git a/scripts/shoot.ts b/scripts/shoot.ts new file mode 100644 index 0000000..fee45c3 --- /dev/null +++ b/scripts/shoot.ts @@ -0,0 +1,40 @@ +// Takes the screenshot each live site is printed with on the desk, into src/assets/shots/.png. +// By default only repos without one are shot, so existing prints don't churn week to week; +// pass --all to retake every one after the sites themselves have changed. +import { access } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { chromium } from "@playwright/test"; +import repos from "../src/data/repos.json" with { type: "json" }; + +const all = process.argv.includes("--all"); +const shot = (name: string) => fileURLToPath(new URL(`../src/assets/shots/${name}.png`, import.meta.url)); +const exists = (path: string) => + access(path).then( + () => true, + () => false, + ); + +// Only sites on the org's Pages origin are printed; the README games and dev.to render live instead. +const targets = repos.filter((r) => r.homepage?.startsWith("https://0-draft.github.io/")); + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" }); +let taken = 0; +let failed = 0; +for (const r of targets) { + const path = shot(r.name); + if (!all && (await exists(path))) continue; + try { + await page.goto(r.homepage as string, { waitUntil: "networkidle", timeout: 45_000 }); + await page.waitForTimeout(1500); + await page.screenshot({ path }); + taken++; + console.log(`shot ${r.name}`); + } catch (err) { + failed++; + console.warn(`fail ${r.name}: ${(err as Error).message.split("\n")[0]}`); + } +} +await browser.close(); +console.log(`${taken} taken, ${failed} failed, ${targets.length - taken - failed} kept`); +if (failed && !taken) process.exit(1); From 90da54708fac2685c8f0ebfb712d5fa7cfa66817 Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 24 Oct 2026 03:41:12 +0900 Subject: [PATCH 2/7] fix(shots): never print an error page, and surface failures in the run summary A 404 or 5xx page was screenshotted like any other, and without --all it would never be retaken. Failed shots now also emit an Actions warning. Raised in Claude review on #3. --- scripts/shoot.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/shoot.ts b/scripts/shoot.ts index fee45c3..ed6f9c2 100644 --- a/scripts/shoot.ts +++ b/scripts/shoot.ts @@ -25,14 +25,19 @@ for (const r of targets) { const path = shot(r.name); if (!all && (await exists(path))) continue; try { - await page.goto(r.homepage as string, { waitUntil: "networkidle", timeout: 45_000 }); + // An error page must never be printed: without --all it would never be retaken. + const res = await page.goto(r.homepage as string, { waitUntil: "networkidle", timeout: 45_000 }); + if (!res?.ok()) throw new Error(`HTTP ${res?.status() ?? "no response"}`); await page.waitForTimeout(1500); await page.screenshot({ path }); taken++; console.log(`shot ${r.name}`); } catch (err) { failed++; - console.warn(`fail ${r.name}: ${(err as Error).message.split("\n")[0]}`); + const why = (err as Error).message.split("\n")[0]; + console.warn(`fail ${r.name}: ${why}`); + // Surfaces in the Actions run summary, not only in the log. + if (process.env.GITHUB_ACTIONS) console.log(`::warning title=No screenshot for ${r.name}::${why}`); } } await browser.close(); From f69e8071c92c994fa37083ce28aa7924ce01a598 Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 24 Oct 2026 03:51:11 +0900 Subject: [PATCH 3/7] fix(shots): shoot with a read-only token, and refuse pages that redirect off-origin The npm install and the sync ran in the job holding contents and pull-request write. Shooting now runs read-only and hands the screenshots to a propose job that installs nothing. A homepage that redirects elsewhere is no longer printed. Raised in Claude review on #3. --- .github/workflows/shots.yml | 33 ++++++++++++++++++++++++++++----- scripts/shoot.ts | 2 ++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/workflows/shots.yml b/.github/workflows/shots.yml index b0f662a..1fecfa4 100644 --- a/.github/workflows/shots.yml +++ b/.github/workflows/shots.yml @@ -39,6 +39,8 @@ jobs: - id: v run: echo "playwright=$(node -p 'require("./package.json").devDependencies["@playwright/test"]')" >> "$GITHUB_OUTPUT" + # Shooting runs third-party code (the npm install, the sites themselves) with a + # read-only token. Only the propose job can write, and it installs nothing. shoot: needs: versions runs-on: ubuntu-latest @@ -47,8 +49,7 @@ jobs: image: mcr.microsoft.com/playwright:v${{ needs.versions.outputs.playwright }}-noble options: --user 1001 permissions: - contents: write - pull-requests: write + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -59,20 +60,42 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} - run: node scripts/shoot.ts ${{ inputs.all && '--all' || '' }} + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: shots + path: src/assets/shots + retention-days: 3 + + propose: + needs: shoot + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: shots + path: src/assets/shots - name: Propose the new screenshots env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} RUN_ID: ${{ github.run_id }} + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com run: | - git config --global --add safe.directory "$GITHUB_WORKSPACE" if [ -z "$(git status --porcelain -- src/assets/shots)" ]; then echo "Every live site already has a screenshot." exit 0 fi branch="shots/$RUN_ID" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git switch -c "$branch" git add -- src/assets/shots git commit -m "chore(shots): update project screenshots" diff --git a/scripts/shoot.ts b/scripts/shoot.ts index ed6f9c2..bd92c60 100644 --- a/scripts/shoot.ts +++ b/scripts/shoot.ts @@ -28,6 +28,8 @@ for (const r of targets) { // An error page must never be printed: without --all it would never be retaken. const res = await page.goto(r.homepage as string, { waitUntil: "networkidle", timeout: 45_000 }); if (!res?.ok()) throw new Error(`HTTP ${res?.status() ?? "no response"}`); + // goto follows redirects; only print what is still on the org's Pages origin. + if (!page.url().startsWith("https://0-draft.github.io/")) throw new Error(`redirected to ${page.url()}`); await page.waitForTimeout(1500); await page.screenshot({ path }); taken++; From 9046a646237213199b58cf5a3b87358efd461a37 Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 24 Oct 2026 03:57:36 +0900 Subject: [PATCH 4/7] fix(shots): commit only real PNGs from the untrusted shoot artifact Raised in Claude review on #3. --- .github/workflows/shots.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/shots.yml b/.github/workflows/shots.yml index 1fecfa4..5e605c9 100644 --- a/.github/workflows/shots.yml +++ b/.github/workflows/shots.yml @@ -95,9 +95,22 @@ jobs: echo "Every live site already has a screenshot." exit 0 fi + # The artifact comes from a job that ran third-party code, so commit nothing + # but real PNGs directly inside src/assets/shots. + git status --porcelain --untracked-files=all -- src/assets/shots | cut -c4- | while read -r f; do + case "$f" in + src/assets/shots/*/*) echo "::error::unexpected path $f"; exit 1 ;; + src/assets/shots/*.png) ;; + *) echo "::error::unexpected file $f"; exit 1 ;; + esac + if [ "$(head -c 8 "$f" | od -An -tx1 | tr -d ' \n')" != "89504e470d0a1a0a" ]; then + echo "::error::$f is not a PNG" + exit 1 + fi + done branch="shots/$RUN_ID" git switch -c "$branch" - git add -- src/assets/shots + git add -- 'src/assets/shots/*.png' git commit -m "chore(shots): update project screenshots" git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$branch" { From c2ad8673eae018d881b2ba4ea407e9178410a41d Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 24 Oct 2026 04:03:01 +0900 Subject: [PATCH 5/7] fix(shots): check for redirects after the page settles, and keep one open shots PR Raised in Claude review on #3. --- .github/workflows/shots.yml | 5 +++++ scripts/shoot.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/shots.yml b/.github/workflows/shots.yml index 5e605c9..7304bd5 100644 --- a/.github/workflows/shots.yml +++ b/.github/workflows/shots.yml @@ -95,6 +95,11 @@ jobs: echo "Every live site already has a screenshot." exit 0 fi + open="$(gh pr list --repo "$REPO" --state open --search 'head:shots/' --json number --jq '.[0].number // empty')" + if [ -n "$open" ]; then + echo "::notice::Screenshots are already proposed in #$open; merge or close it first." + exit 0 + fi # The artifact comes from a job that ran third-party code, so commit nothing # but real PNGs directly inside src/assets/shots. git status --porcelain --untracked-files=all -- src/assets/shots | cut -c4- | while read -r f; do diff --git a/scripts/shoot.ts b/scripts/shoot.ts index bd92c60..98eab7c 100644 --- a/scripts/shoot.ts +++ b/scripts/shoot.ts @@ -28,9 +28,10 @@ for (const r of targets) { // An error page must never be printed: without --all it would never be retaken. const res = await page.goto(r.homepage as string, { waitUntil: "networkidle", timeout: 45_000 }); if (!res?.ok()) throw new Error(`HTTP ${res?.status() ?? "no response"}`); - // goto follows redirects; only print what is still on the org's Pages origin. - if (!page.url().startsWith("https://0-draft.github.io/")) throw new Error(`redirected to ${page.url()}`); await page.waitForTimeout(1500); + // Checked after the wait so a late client-side redirect is caught too: + // only print what is still on the org's Pages origin. + if (!page.url().startsWith("https://0-draft.github.io/")) throw new Error(`redirected to ${page.url()}`); await page.screenshot({ path }); taken++; console.log(`shot ${r.name}`); From fa26200f6fb35488d1a18f1c0d591f0f46172acb Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 24 Oct 2026 04:23:28 +0900 Subject: [PATCH 6/7] fix(shots): match open shots PRs by branch prefix Raised in Claude review on #3. --- .github/workflows/shots.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/shots.yml b/.github/workflows/shots.yml index 7304bd5..cf3dc14 100644 --- a/.github/workflows/shots.yml +++ b/.github/workflows/shots.yml @@ -95,7 +95,9 @@ jobs: echo "Every live site already has a screenshot." exit 0 fi - open="$(gh pr list --repo "$REPO" --state open --search 'head:shots/' --json number --jq '.[0].number // empty')" + # head: in a search is an exact branch match, so filter the prefix with jq instead. + open="$(gh pr list --repo "$REPO" --state open --limit 100 --json number,headRefName \ + --jq '[.[] | select(.headRefName | startswith("shots/"))][0].number // empty')" if [ -n "$open" ]; then echo "::notice::Screenshots are already proposed in #$open; merge or close it first." exit 0 From ff44c751d5393bd9ff46b934e523094ecb19a2d8 Mon Sep 17 00:00:00 2001 From: kanywst Date: Sat, 24 Oct 2026 04:31:14 +0900 Subject: [PATCH 7/7] fix(shots): only fail the run when every site fails Raised in Claude review on #3. --- scripts/shoot.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/shoot.ts b/scripts/shoot.ts index 98eab7c..513a7f5 100644 --- a/scripts/shoot.ts +++ b/scripts/shoot.ts @@ -45,4 +45,6 @@ for (const r of targets) { } await browser.close(); console.log(`${taken} taken, ${failed} failed, ${targets.length - taken - failed} kept`); -if (failed && !taken) process.exit(1); +// One broken site is a warning, not a red run every Monday. Only when every +// target fails is something wrong with the run itself (network, browser). +if (targets.length > 0 && failed === targets.length) process.exit(1);