diff --git a/.github/workflows/shots.yml b/.github/workflows/shots.yml new file mode 100644 index 0000000..cf3dc14 --- /dev/null +++ b/.github/workflows/shots.yml @@ -0,0 +1,130 @@ +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" + + # 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 + timeout-minutes: 20 + container: + image: mcr.microsoft.com/playwright:v${{ needs.versions.outputs.playwright }}-noble + options: --user 1001 + permissions: + contents: read + 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' || '' }} + - 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: | + if [ -z "$(git status --porcelain -- src/assets/shots)" ]; then + echo "Every live site already has a screenshot." + exit 0 + fi + # 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 + 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/*.png' + 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..513a7f5 --- /dev/null +++ b/scripts/shoot.ts @@ -0,0 +1,50 @@ +// 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 { + // 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); + // 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}`); + } catch (err) { + failed++; + 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(); +console.log(`${taken} taken, ${failed} failed, ${targets.length - taken - failed} kept`); +// 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);