From 97186cde6dc470221a25187d9ffe2e19042cda5f Mon Sep 17 00:00:00 2001 From: Charlie Nicholson Date: Tue, 27 Oct 2026 11:51:04 -0700 Subject: [PATCH 1/2] Keep deployed scripts available to cached simulator pages --- .github/workflows/deploy.yml | 16 +++++++++++++ scripts/preserve-page-assets.mjs | 31 +++++++++++++++++++++++++ tests/page-assets.test.mjs | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 scripts/preserve-page-assets.mjs create mode 100644 tests/page-assets.test.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 20807b0..78fd13c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,7 @@ jobs: permissions: contents: read pages: read + actions: read steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -59,6 +60,21 @@ jobs: env: SITE_URL: ${{ github.repository == 'badger/badger.github.io' && 'https://badger.github.io' || steps.pages.outputs.origin }} BASE_PATH: ${{ steps.pages.outputs.base_path }} + - name: Preserve assets for cached pages + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + previous_runs=$(gh run list --workflow deploy.yml --branch main --status success --limit 2 --json databaseId --jq '.[].databaseId') + previous_directories=() + for run_id in $previous_runs; do + archive_directory=".previous-pages/$run_id" + mkdir -p "$archive_directory/site" + gh run download "$run_id" --name github-pages --dir "$archive_directory" + tar -xf "$archive_directory/artifact.tar" -C "$archive_directory/site" + previous_directories+=("$archive_directory/site") + done + node scripts/preserve-page-assets.mjs dist "${previous_directories[@]}" - name: Upload artifact uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: diff --git a/scripts/preserve-page-assets.mjs b/scripts/preserve-page-assets.mjs new file mode 100644 index 0000000..a8cccfe --- /dev/null +++ b/scripts/preserve-page-assets.mjs @@ -0,0 +1,31 @@ +import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' + +const assetPattern = /^(?:entry\.[\w-]+\.js|chunks\/chunk\.[\w-]+\.js|assets\/[^/]+\.[\w-]+\.[\w]+)$/ + +export async function preservePageAssets(current, previous) { + const ownAssets = (await readdir(current, { recursive: true })).filter(path => assetPattern.test(path)).sort() + const present = new Set(ownAssets) + for (const directory of previous) { + let paths + try { + paths = JSON.parse(await readFile(resolve(directory, 'page-assets.json'), 'utf8')) + if (!Array.isArray(paths)) throw new Error('Invalid page asset manifest') + } catch (error) { + if (error.code !== 'ENOENT') throw error + paths = await readdir(directory, { recursive: true }) + } + for (const path of paths) { + if (typeof path !== 'string' || !assetPattern.test(path) || present.has(path)) continue + const destination = resolve(current, path) + await mkdir(dirname(destination), { recursive: true }) + await copyFile(resolve(directory, path), destination) + present.add(path) + } + } + await writeFile(resolve(current, 'page-assets.json'), JSON.stringify(ownAssets)) +} + +if (process.argv[1] && import.meta.url === new URL(process.argv[1], 'file:').href) { + await preservePageAssets(resolve(process.argv[2] || 'dist'), process.argv.slice(3).map(path => resolve(path))) +} diff --git a/tests/page-assets.test.mjs b/tests/page-assets.test.mjs new file mode 100644 index 0000000..907de19 --- /dev/null +++ b/tests/page-assets.test.mjs @@ -0,0 +1,39 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { preservePageAssets } from '../scripts/preserve-page-assets.mjs' + +test('cached HTML keeps its entry script and imported chunks across deployments', async t => { + const root = await mkdtemp(join(tmpdir(), 'badger-assets-')) + t.after(() => rm(root, { recursive: true, force: true })) + const current = join(root, 'current') + const previous = join(root, 'previous') + const put = async (directory, path, content) => { + await mkdir(dirname(join(directory, path)), { recursive: true }) + await writeFile(join(directory, path), content) + } + await put(current, 'entry.current.js', 'current') + await put(current, 'entry.shared.js', 'new shared') + await put(current, 'simulator/index.html', 'current HTML') + await put(previous, 'entry.old.js', 'import "./chunks/chunk.old.js"') + await put(previous, 'entry.shared.js', 'old shared') + await put(previous, 'chunks/chunk.old.js', 'old chunk') + await put(previous, 'assets/editor.old.css', 'old CSS') + await put(previous, 'simulator/index.html', 'old HTML') + await put(previous, 'secrets.py', 'excluded') + await preservePageAssets(current, [previous]) + assert.equal(await readFile(join(current, 'entry.old.js'), 'utf8'), 'import "./chunks/chunk.old.js"') + assert.equal(await readFile(join(current, 'chunks/chunk.old.js'), 'utf8'), 'old chunk') + assert.equal(await readFile(join(current, 'assets/editor.old.css'), 'utf8'), 'old CSS') + assert.equal(await readFile(join(current, 'entry.shared.js'), 'utf8'), 'new shared') + assert.equal(await readFile(join(current, 'simulator/index.html'), 'utf8'), 'current HTML') + await assert.rejects(readFile(join(current, 'secrets.py')), { code: 'ENOENT' }) + assert.deepEqual(JSON.parse(await readFile(join(current, 'page-assets.json'), 'utf8')), ['entry.current.js', 'entry.shared.js']) + const next = join(root, 'next') + await put(next, 'entry.next.js', 'next') + await preservePageAssets(next, [current]) + assert.equal(await readFile(join(next, 'entry.current.js'), 'utf8'), 'current') + await assert.rejects(readFile(join(next, 'entry.old.js')), { code: 'ENOENT' }) +}) From f15bca006d08c5a1f03adbe12f8748fc214d625b Mon Sep 17 00:00:00 2001 From: Charlie Nicholson Date: Tue, 27 Oct 2026 12:01:04 -0700 Subject: [PATCH 2/2] Handle unavailable historical Pages artifacts --- .github/workflows/deploy.yml | 16 ++++-- tests/pages-deploy-assets.test.mjs | 83 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/pages-deploy-assets.test.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78fd13c..9d34ff1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -65,13 +65,22 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} run: | - previous_runs=$(gh run list --workflow deploy.yml --branch main --status success --limit 2 --json databaseId --jq '.[].databaseId') + if ! previous_runs=$(gh run list --workflow deploy.yml --branch main --status success --limit 2 --json databaseId --jq '.[].databaseId'); then + echo "::warning::Could not list previous deployments. Deploying current assets only." + previous_runs='' + fi previous_directories=() for run_id in $previous_runs; do archive_directory=".previous-pages/$run_id" mkdir -p "$archive_directory/site" - gh run download "$run_id" --name github-pages --dir "$archive_directory" - tar -xf "$archive_directory/artifact.tar" -C "$archive_directory/site" + if ! gh run download "$run_id" --name github-pages --dir "$archive_directory"; then + echo "::warning::Pages artifact for run $run_id is unavailable or expired. Skipping historical assets." + continue + fi + if ! tar -xf "$archive_directory/artifact.tar" -C "$archive_directory/site"; then + echo "::warning::Pages artifact for run $run_id could not be extracted. Skipping historical assets." + continue + fi previous_directories+=("$archive_directory/site") done node scripts/preserve-page-assets.mjs dist "${previous_directories[@]}" @@ -79,6 +88,7 @@ jobs: uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: dist + retention-days: 30 deploy: needs: build diff --git a/tests/pages-deploy-assets.test.mjs b/tests/pages-deploy-assets.test.mjs new file mode 100644 index 0000000..4faf80f --- /dev/null +++ b/tests/pages-deploy-assets.test.mjs @@ -0,0 +1,83 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { copyFile, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const workflow = await readFile(new URL('../.github/workflows/deploy.yml', import.meta.url), 'utf8') +const step = workflow.match(/ - name: Preserve assets for cached pages\n[\s\S]*? run: \|\n((?: .*\n)+)/)[1].replace(/^ {10}/gm, '') + +async function runStep(t, runs, listFailure = false) { + const directory = await mkdtemp(join(tmpdir(), 'badger-deploy-assets-')) + t.after(() => rm(directory, { recursive: true, force: true })) + await mkdir(join(directory, 'dist'), { recursive: true }) + await mkdir(join(directory, 'scripts')) + await mkdir(join(directory, 'previous/chunks'), { recursive: true }) + await writeFile(join(directory, 'dist/entry.current.js'), 'current') + await writeFile(join(directory, 'previous/entry.previous.js'), 'import "./chunks/chunk.previous.js"') + await writeFile(join(directory, 'previous/chunks/chunk.previous.js'), 'previous chunk') + await copyFile(new URL('../scripts/preserve-page-assets.mjs', import.meta.url), join(directory, 'scripts/preserve-page-assets.mjs')) + const archive = join(directory, 'previous.tar') + const tar = spawnSync('tar', ['-cf', archive, '-C', join(directory, 'previous'), '.'], { encoding: 'utf8' }) + assert.equal(tar.status, 0, tar.stderr) + const mock = ` +gh() { + if [[ "$1 $2" == "run list" ]]; then + if [[ "$TEST_LIST_FAILURE" == "true" ]]; then return 1; fi + printf '%s' "$TEST_RUNS" + return 0 + fi + case "$3" in + 101) echo 'artifact expired' >&2; return 1 ;; + 102) echo 'no artifact found' >&2; return 1 ;; + 103) cp "$TEST_ARCHIVE" "$7/artifact.tar" ;; + 104) printf 'invalid archive' > "$7/artifact.tar" ;; + *) return 2 ;; + esac +} +node() { command "$TEST_NODE" "$@"; } +` + const result = spawnSync('bash', ['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', mock + step], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, TEST_RUNS: runs.join('\n'), TEST_LIST_FAILURE: String(listFailure), TEST_ARCHIVE: archive, TEST_NODE: process.execPath }, + }) + assert.equal(result.status, 0, result.stdout + result.stderr) + assert.equal(await readFile(join(directory, 'dist/entry.current.js'), 'utf8'), 'current') + assert.deepEqual(JSON.parse(await readFile(join(directory, 'dist/page-assets.json'), 'utf8')), ['entry.current.js']) + return { directory, output: result.stdout } +} + +test('expired and missing historical artifacts do not block the current deployment', async t => { + const { directory, output } = await runStep(t, ['101', '102']) + assert.equal(output.match(/::warning::/g)?.length, 2) + await assert.rejects(readFile(join(directory, 'dist/entry.previous.js')), { code: 'ENOENT' }) +}) + +test('available historical scripts and their chunks survive another artifact expiring', async t => { + const { directory, output } = await runStep(t, ['101', '103']) + assert.match(output, /::warning::.*101/) + assert.equal(await readFile(join(directory, 'dist/entry.previous.js'), 'utf8'), 'import "./chunks/chunk.previous.js"') + assert.equal(await readFile(join(directory, 'dist/chunks/chunk.previous.js'), 'utf8'), 'previous chunk') +}) + +test('invalid historical archives do not block the current deployment', async t => { + const { output } = await runStep(t, ['104']) + assert.match(output, /::warning::.*could not be extracted/) +}) + +test('deployments proceed when previous runs cannot be listed', async t => { + const { output } = await runStep(t, [], true) + assert.match(output, /::warning::Could not list previous deployments/) +}) + +test('the first deployment needs no historical artifacts', async t => { + const { output } = await runStep(t, []) + assert.equal(output, '') +}) + +test('Pages artifacts have an explicit thirty-day retention policy', () => { + const upload = workflow.match(/ - name: Upload artifact\n[\s\S]*?(?=\n deploy:)/)[0] + assert.match(upload, /retention-days: 30/) +})