chore(e2e): update reference screenshots for #2679 #4089
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
| 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, | |
| }); | |
| } |