Skip to content

chore(e2e): update reference screenshots for #2679 #4089

chore(e2e): update reference screenshots for #2679

chore(e2e): update reference screenshots for #2679 #4089

Workflow file for this run

name: Demo preview
# Publishes browser demos and marketing-site bundles under the Pages /demo/
# namespace:
# /demo/main/ and /demo/release/ are persistent branch builds.
# /demo/main/preview/ is the trunk marketing site.
# /demo/pr-<n>/ is the ephemeral demo URL for a PR.
# /demo/pr-<n>-preview/ is that PR's marketing site.
#
# Each bundle links the demo published beside it (../pr-<n>/ or ../) rather than
# carrying its own nested copy, so there is exactly one build of the demo per
# target instead of two identical ones. That is also why there is no longer a
# /demo/pr-<n>-preview/demo/main/ URL. A bundle is therefore NOT self-contained:
# it needs the sibling flat build, which is published and pruned with it.
#
# Triggered on push (not workflow_run) so the workflow runs from the pushed
# branch's own definition — which makes the whole flow testable on the PR that
# introduces it, before merge. This is safe because only same-repo branches can
# push here: forks push to their own fork and never trigger this repo's push
# workflows, so untrusted code never reaches the write token or the deploy. Each
# qualifying push rebuilds the demo and, ONLY IF the built bytes differ from
# what is already published, releases it into the machine-managed demo-previews
# branch and redeploys Pages. Feature branches without an open PR build nothing.
#
# The build reads the renderer, shared, preload and site trees, so the common
# case on this repo — a docs, test, benchmark or main-process commit — rebuilds
# identical bytes and stops at the no-change check, publishing nothing and
# deploying nothing.
#
# The deploy always republishes the root site/ from `main` (see pages.yml), so a
# preview push can only change content below /demo/ — never the production
# marketing site at the domain root.
#
# Everything published below /demo/ is marked `noindex, nofollow`: the demos by
# `build:demo`, the marketing-site bundles by the publish step here. Previews are
# near-duplicates of copse.dev/ that appear and vanish with pull requests, and
# their links are public, so indexing them competes with the production site for
# its own copy and leaves dead /demo/pr-<n>/ results behind. The production root
# carries no such tag and stays indexable — see scripts/lib/noindex.mts.
on:
push:
branches-ignore:
- demo-previews
workflow_dispatch:
# No pages/id-token here any more: nothing in this workflow deploys. The deploy
# runs on the run `deploy` dispatches, which mints those scopes from pages.yml's
# own `permissions:` block rather than inheriting a caller's. `actions: write` is
# what the dispatch needs and is declared on that job alone, so `publish` — the
# one job here that runs a shell script against a write token — does not get it.
permissions:
contents: write
pull-requests: write
# A newer push to the same branch supersedes an in-flight preview build.
concurrency:
group: demo-preview-${{ github.ref }}
cancel-in-progress: true
jobs:
publish:
# Skip forks entirely (their pushes don't trigger this anyway; explicit for
# clarity). Prefer the self-hosted check fleet when configured.
if: github.repository == 'copse-dev/agent-pane'
runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }}
outputs:
found: ${{ steps.target.outputs.found }}
pr: ${{ steps.target.outputs.pr }}
published: ${{ steps.commit.outputs.published }}
scenarios: ${{ steps.scenarios.outputs.json }}
steps:
- uses: actions/checkout@v7.0.1
# main/release always publish to their persistent slots. Other branches
# belong to an open PR; no PR means nothing to preview or comment on.
- id: target
uses: actions/github-script@v9
with:
script: |
const branch = context.ref.replace('refs/heads/', '');
if (branch === 'release' || branch === 'main') {
core.setOutput('found', 'true');
core.setOutput('path', branch);
core.setOutput('label', branch);
core.setOutput('pr', '');
return;
}
const { data } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:${branch}`,
state: 'open',
});
if (data.length === 0) {
console.log(`No open PR for ${branch}; skipping preview.`);
core.setOutput('found', 'false');
return;
}
const pr = String(data[0].number);
core.setOutput('found', 'true');
core.setOutput('path', `pr-${pr}`);
core.setOutput('label', `PR #${pr}`);
core.setOutput('pr', pr);
- if: steps.target.outputs.found == 'true'
uses: ./.github/actions/setup
# Point the demo at the shared Monaco tree rather than bundling its own.
# Versioned, so a monaco-editor bump publishes a new tree beside the old one
# instead of breaking every preview built against the previous version.
#
# RELATIVE, deliberately. Every demo is published exactly one level under
# /demo/ — /demo/main/, /demo/release/, /demo/pr-<n>/ — so `../vendor/…`
# resolves to /demo/vendor/… from all of them, whether Pages serves the
# custom-domain root in site/CNAME or an <owner>.github.io/<repo>/ prefix.
# This used to carry a hardcoded /<repo>/ prefix, which the copse.dev root
# does not have, so every worker 404'd. A root-relative /demo/vendor/… is
# correct today but re-adopts the same assumption in the other direction.
# `monacoVsRoot()` resolves the value with `new URL(base, location.href)`,
# so a relative base is fine. It only became viable once preview bundles
# stopped nesting a second demo at the deeper …-preview/demo/main/ path.
- id: monaco
if: steps.target.outputs.found == 'true'
run: |
set -euo pipefail
# Read the file, don't `require('monaco-editor/package.json')`: monaco's
# `exports` map has no "./package.json" entry, so Node resolves the
# specifier through the map and fails with
# "Cannot find module .../esm/vs/package.json.js".
version="$(node -p "JSON.parse(require('node:fs').readFileSync('node_modules/monaco-editor/package.json','utf8')).version")"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "base=../vendor/monaco/${version}/" >> "$GITHUB_OUTPUT"
- if: steps.target.outputs.found == 'true'
env:
MONACO_BASE_URL: ${{ steps.monaco.outputs.base }}
run: npm run build:demo
- id: scenarios
if: steps.target.outputs.found == 'true'
run: |
{
echo 'json<<SCENARIOS_EOF'
tr -d '\n' < dist/demo/scenarios.json 2>/dev/null || echo '[]'
echo
echo 'SCENARIOS_EOF'
} >> "$GITHUB_OUTPUT"
- id: commit
if: steps.target.outputs.found == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TARGET: ${{ steps.target.outputs.path }}
LABEL: ${{ steps.target.outputs.label }}
SHA: ${{ github.sha }}
MONACO_VERSION: ${{ steps.monaco.outputs.version }}
run: |
set -euo pipefail
if [[ "$TARGET" != "release" && "$TARGET" != "main" && ! "$TARGET" =~ ^pr-[0-9]+$ ]]; then
echo "Invalid demo target: $TARGET" >&2
exit 1
fi
remote="https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git"
# Work on an isolated clone of the previews branch so the demo build in
# dist/ is never disturbed. Create the orphan branch on first publish.
if git clone --depth=1 --branch demo-previews "$remote" previews-branch 2>/dev/null; then
echo "Updating existing demo-previews branch"
else
git clone --depth=1 "$remote" previews-branch
git -C previews-branch checkout --orphan demo-previews
git -C previews-branch rm -rf . >/dev/null 2>&1 || true
fi
git -C previews-branch config user.name "github-actions[bot]"
git -C previews-branch config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Every branch publishes into demo-previews, but `concurrency` is keyed
# on github.ref, so two branches building at once are NOT serialised —
# they clone the same tip and the loser's push is rejected non-fast-
# forward ("Updates were rejected because the remote contains work that
# you do not have locally"), failing the job on a change that was
# perfectly valid. Targets never share a directory (main/, release/,
# pr-<n>/, pr-<n>-preview/), so the loser just needs to re-stack onto
# the winner's tip: re-fetch, hard-reset onto the new tip, re-apply
# this target's already-built tree, re-commit, push again.
published=false
unchanged=false
# The first attempt's commit. Later attempts re-apply it onto the tip
# that beat them rather than rebuilding the same bytes; empty until the
# build below has produced one.
restack_from=""
attempts=12
for attempt in $(seq 1 "$attempts"); do
if [ "$attempt" -gt 1 ]; then
echo "Push rejected; restacking onto the new demo-previews tip (attempt ${attempt}/${attempts})"
# Back off with jitter, capping the base so a dozen attempts stay
# bounded. One push to main fans this workflow out into a dozen runs
# that all start within seconds of each other, so a fixed backoff
# retried the whole herd in lockstep: the losers collided again on
# every attempt and ran out of them on contents that were never
# wrong (2026-08-29T14:48Z, five straight rejections on an untouched
# PR). The random term is what breaks the lockstep; the raised
# ceiling covers a herd that can only drain one run per round.
sleep $(( (attempt < 6 ? attempt * 5 : 30) + RANDOM % 10 ))
# The clone is shallow, so refresh it the same way rather than pulling
# full history. An orphan first publish has no remote branch to fetch;
# in that case there is nothing to restack onto and the retry is a
# plain re-push. `clean -fdx` also drops the previous attempt's
# untracked bundle dirs so the re-apply below starts from the tip.
if git -C previews-branch fetch --depth=1 origin demo-previews 2>/dev/null; then
git -C previews-branch reset --hard FETCH_HEAD
git -C previews-branch clean -fdx
fi
fi
# Publish the shared Monaco tree once per version. Every preview
# points at this instead of carrying its own 34MB copy — which is
# what made this branch grow by 34MB per preview per push, forever,
# since removing a closed PR's directory leaves the history behind.
monaco_dir="previews-branch/vendor/monaco/${MONACO_VERSION}"
if [ ! -d "$monaco_dir" ]; then
echo "Publishing shared Monaco ${MONACO_VERSION}"
mkdir -p "$monaco_dir"
node scripts/copy-monaco-workers.mts "$monaco_dir"
git -C previews-branch add "vendor/monaco/${MONACO_VERSION}"
fi
# Build the publishable tree on the first pass only. The monaco
# check above still runs every attempt, because a restack can land
# on a tip that does not carry this version yet.
if [ -z "$restack_from" ]; then
rm -rf "previews-branch/${TARGET}"
mkdir -p "previews-branch/${TARGET}"
cp -R dist/demo/. "previews-branch/${TARGET}/"
# site/index.html links the demo relatively, as `demo/main/`, which is
# correct from the site root. A bundle served from a subdirectory used
# to satisfy that by nesting a second, identical copy of the whole
# demo inside itself — doubling every preview's size and producing the
# /demo/pr-<n>-preview/demo/main/ URL. Point those links at the flat
# build published beside it instead. Relative rather than
# root-relative, so it holds whether Pages serves from the custom
# domain root or from an <owner>.github.io/<repo>/ prefix.
point_site_at_demo() {
local file="previews-branch/$1/index.html"
if ! grep -q '"demo/main/' "$file"; then
echo "site/index.html no longer links \"demo/main/\" — update this rewrite" >&2
exit 1
fi
sed -i "s|\"demo/main/|\"$2|g" "$file"
}
# These bundles are copies of the production marketing site served
# under /demo/, so they are near-duplicates of copse.dev/ and of each
# other, and they come and go with pull requests. Mark them noindex
# here, on the copy — site/ itself stays indexable, and pages.yml
# deploys it to the root from main untouched. The flat demo published
# beside them is already marked by `build:demo`. GitHub Pages cannot
# set an X-Robots-Tag header, hence a meta tag; see
# scripts/lib/noindex.mts.
mark_bundle_noindex() {
node scripts/mark-noindex.mts "previews-branch/$1"
}
if [[ "$TARGET" == "main" ]]; then
mkdir -p "previews-branch/${TARGET}/preview"
cp -R site/. "previews-branch/${TARGET}/preview/"
# ../ from /demo/main/preview/ is /demo/main/, the flat build above.
point_site_at_demo "${TARGET}/preview" "../"
mark_bundle_noindex "${TARGET}/preview"
elif [[ "$TARGET" =~ ^pr-[0-9]+$ ]]; then
bundle_target="${TARGET}-preview"
rm -rf "previews-branch/${bundle_target}"
mkdir -p "previews-branch/${bundle_target}"
cp -R site/. "previews-branch/${bundle_target}/"
# ../pr-<n>/ from /demo/pr-<n>-preview/ is the flat build above.
point_site_at_demo "$bundle_target" "../${TARGET}/"
mark_bundle_noindex "$bundle_target"
find "previews-branch/${bundle_target}" -type f -name '*.map' -delete
git -C previews-branch add "$bundle_target"
fi
# Never commit source maps: gitleaks scans every ref (fetch-depth: 0),
# and high-entropy map contents fail Secret scan on unrelated tips.
find "previews-branch/${TARGET}" -type f -name '*.map' -delete
git -C previews-branch add "$TARGET"
publish_paths=("$TARGET")
if [ -n "${bundle_target:-}" ]; then
publish_paths+=("$bundle_target")
fi
else
# A retry changes which tip this target sits on, never what it
# publishes, so re-apply the rejected commit's version of these
# paths instead of re-copying ~1k files and re-hashing them into
# the index. Nothing here reads dist/ or site/ again, and the
# narrower the gap between fetching the tip and pushing, the more
# likely this run is the one whose push lands. Uncache first, so
# that a file the winner's tip carries under these paths which
# this build no longer produces is staged as a deletion — which
# is what `git add <dir>` does on the first pass.
for path in "${publish_paths[@]}"; do
git -C previews-branch rm -r -q --cached --ignore-unmatch -- "$path"
git -C previews-branch checkout "$restack_from" -- "$path"
done
fi
if git -C previews-branch diff --cached --quiet; then
# Reached on the first pass when the build is byte-identical to what
# is already published, and on a retry when the winning push carried
# the same content for this target.
#
# NOTHING under the target directory may be derived from the commit
# SHA, or this check can never be true and every push republishes
# and redeploys. A `.head-sha` provenance stamp used to be written
# here and staged just above, which did exactly that: it changed on
# every push, so the diff was never quiet, so a docs-only or
# test-only commit still triggered a full-tree Pages deploy. Nothing
# ever read the file. Provenance lives in the commit message below,
# where it belongs; `git log demo-previews -- <target>/` recovers it.
echo "No preview change for ${LABEL}"
unchanged=true
break
fi
git -C previews-branch commit -m "demo preview: ${LABEL} (${SHA})"
restack_from="$(git -C previews-branch rev-parse HEAD)"
if git -C previews-branch push origin demo-previews; then
published=true
break
fi
done
if [ "$published" = false ] && [ "$unchanged" = false ]; then
echo "Could not publish ${LABEL} to demo-previews after ${attempts} attempts" >&2
exit 1
fi
echo "published=${published}" >> "$GITHUB_OUTPUT"
# Deliberately NOT `uses: ./.github/workflows/pages.yml`. A called workflow's
# jobs run inside THIS run, so the deploy took its place in the shared `pages`
# concurrency group here — and GitHub keeps a single pending slot, so a newer
# deploy arriving while ours waited cancelled ours, which marked this whole run
# cancelled and painted a grey X on the PR. That is not a rare edge: 23 of the
# 40 Demo preview runs before this change ended `cancelled`, on 23 different
# branches, every one of them a `publish` and `comment` that had already
# succeeded. One push to main fans this workflow out into a dozen runs whose
# deploys all reach the queue within seconds, so every one past the deploying
# run and the single pending one behind it is cancelled.
#
# Dispatching instead hands the deploy to a run of its own. The supersession
# still happens — it is the right behaviour, and pages.yml's note explains why
# `queue: max` was worse — but it now cancels a standalone "Deploy site" run
# that no PR is watching, instead of a PR's own preview run.
#
# Nothing is lost by not waiting: the deploy assembles whatever is on the
# demo-previews tip rather than this run's content, and `publish` has already
# pushed there by the time we get here. Whichever deploy runs next therefore
# carries this target, which is also why the sticky `comment` below never
# waited for it either.
#
# `workflow_dispatch`, not a `push:` trigger on demo-previews: that branch is an
# orphan holding preview content with no `.github/` of its own, and a push event
# runs the workflow definitions from the pushed branch — so such a trigger would
# silently never fire. Dispatching on THIS branch keeps what `uses:` gave us,
# which is that a PR editing pages.yml exercises its own copy before merge.
#
# It also has to be one of those two triggers to fire at all. `github.token`
# normally starts no further run — the recursion guard ci.yml's autoformat job
# works around by pushing as an App — and `workflow_dispatch` and
# `repository_dispatch` are the documented exceptions to it. Reaching for any
# other event here would produce a dispatch that returns 204 and does nothing.
deploy:
needs: publish
if: needs.publish.outputs.published == 'true'
runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }}
permissions:
actions: write
steps:
- uses: actions/github-script@v9
with:
script: |
const workflow_id = 'pages.yml';
// Warn, never fail — this carries over the `tolerate-deploy-failure:
// true` the `uses:` call used to pass. A deploy problem is never this
// PR's fault: the previews branch is shared, and so are the Pages
// service and the deploy queue. The build is already committed to
// demo-previews, so the next deploy publishes it either way.
//
// The concrete case this catches is a PR that merges while its own
// preview is still building: auto-delete takes the head branch with
// it, and dispatching on a ref that no longer exists 404s. ci.yml's
// autoformat job guards the same trap. Failing there would put a red
// X on an already-merged PR over a deploy nobody is waiting for.
try {
// A deploy that is already QUEUED has not assembled yet, so it will
// pick up the commit `publish` just pushed — dispatching a second one
// would only supersede it to publish the same tree. An IN-PROGRESS
// deploy is not enough: it may have fetched the previews tip before
// our push landed, so that case still needs a deploy of its own.
//
// Racing runs can both read "none queued" and both dispatch; the
// loser is superseded exactly as before, on a run no PR is watching.
// This is an optimisation, never a correctness condition.
//
// Deploys that demo-preview-cleanup.yml still calls with `uses:` are
// jobs inside ITS run and so are invisible here. Same story: at worst
// we dispatch alongside one and supersede it, and the deploy that
// wins assembles the tip both of them wanted published.
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id,
per_page: 30,
});
const pending = data.workflow_runs.find(
(run) => run.status === 'queued' || run.status === 'pending',
);
if (pending) {
core.info(
`Deploy run ${pending.id} is already queued and will publish this build; skipping.`,
);
return;
}
const ref = context.ref.replace('refs/heads/', '');
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id,
ref,
});
core.info(`Dispatched the Pages deploy on ${ref}.`);
} catch (err) {
core.warning(
`Pages deploy not dispatched (${err.message}). This build is already committed to demo-previews, so the next deploy publishes it.`,
{ title: 'Pages deploy not dispatched' },
);
}
# Deliberately NOT `needs: deploy`, and it predates the dispatch above: back
# when the deploy ran in this workflow it serialized on the shared `pages`
# concurrency group, so gating the comment on it meant the preview link arrived
# whenever that queue got around to this run — measured at seven hours after
# the push on a busy day. The comment describes URLs that some deploy will serve
# shortly, so it does not need to wait for one, and it survives a deploy that
# gets superseded by a newer one. `needs: publish` also keeps it independent of
# the dispatch, which is a request rather than a result.
comment:
needs: publish
if: needs.publish.outputs.published == 'true' && needs.publish.outputs.pr != ''
runs-on: ${{ vars.SELF_HOSTED_CHECKS || 'ubuntu-latest' }}
permissions:
pull-requests: write
pages: read
steps:
- uses: actions/github-script@v9
env:
PR: ${{ needs.publish.outputs.pr }}
SCENARIOS: ${{ needs.publish.outputs.scenarios }}
with:
script: |
const marker = '<!-- copse-demo-preview -->';
const pr = Number(process.env.PR);
// Ask Pages for its own base URL rather than hard-coding one. This
// repo publishes under the site/CNAME custom domain, so the
// <owner>.github.io/<repo>/ form would 404 — and it would silently
// become the right answer again if the domain were ever dropped.
// Normalise to exactly one trailing slash, then use the shared
// /demo/ namespace; that final slash matters because the demo's
// assets are document-relative.
let root = 'https://copse.dev/';
try {
const { data: pages } = await github.rest.repos.getPages({
owner: context.repo.owner,
repo: context.repo.repo,
});
if (pages.html_url) root = pages.html_url;
} catch (err) {
console.log(`Could not read the Pages URL (${err.message}); using ${root}`);
}
const base = root.replace(/\/*$/, '/');
const previewUrl = `${base}demo/pr-${pr}-preview/`;
// The flat build, not a path inside the bundle: the bundle links this
// one rather than nesting its own copy.
const demoUrl = `${base}demo/pr-${pr}/`;
let scenarios = [];
try {
scenarios = JSON.parse(process.env.SCENARIOS || '[]');
} catch {
scenarios = [];
}
const links = scenarios
.map((s) => `- [${s.label}](${demoUrl}?scenario=${encodeURIComponent(s.id)})`)
.join('\n');
const body = [
marker,
'## 🖥️ PR preview',
'',
`- **[Marketing site](${previewUrl})**`,
`- **[Browser demo](${demoUrl})**`,
'',
links ? 'Interactive, with a mocked backend — jump to a scenario:' : '',
links,
'',
'<sub>Static demo of the renderer against a mock API — the browser pane, terminal, and all main-process IPC are stubbed, so it never proves main-process behaviour. Rebuilt whenever a push changes the demo bundle; these links go live once the queued deploy lands, shortly after this comment. Removed when the PR closes.</sub>',
]
.filter((line) => line !== '')
.join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr,
});
const existing = comments.find(
(c) =>
c.user?.type === 'Bot' &&
typeof c.body === 'string' &&
c.body.includes(marker),
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr,
body,
});
}