Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/workflows/shots.yml
Original file line number Diff line number Diff line change
@@ -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 }}
Comment thread
kanywst marked this conversation as resolved.
- 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"
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<repo>.png`.
3. Run `node scripts/shoot.ts`, or wait for the weekly shots workflow, to get its screenshot at `src/assets/shots/<repo>.png`.
50 changes: 50 additions & 0 deletions scripts/shoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Takes the screenshot each live site is printed with on the desk, into src/assets/shots/<repo>.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"}`);
Comment thread
kanywst marked this conversation as resolved.
await page.waitForTimeout(1500);
Comment thread
kanywst marked this conversation as resolved.
// 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);
Loading