Repository navigation
ci: take screenshots for new live sites weekly and propose them as a PR #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
3d2f5d3
ci: take screenshots for new live sites weekly and propose them as a PR
kanywst 90da547
fix(shots): never print an error page, and surface failures in the ru…
kanywst f69e807
fix(shots): shoot with a read-only token, and refuse pages that redir…
kanywst 9046a64
fix(shots): commit only real PNGs from the untrusted shoot artifact
kanywst c2ad867
fix(shots): check for redirects after the page settles, and keep one …
kanywst fa26200
fix(shots): match open shots PRs by branch prefix
kanywst ff44c75
fix(shots): only fail the run when every site fails
kanywst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| 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 }} | ||
| - 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" | ||
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
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
| 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"}`); | ||
|
kanywst marked this conversation as resolved.
|
||
| await page.waitForTimeout(1500); | ||
|
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); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.