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
26 changes: 26 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
permissions:
contents: read
pages: read
actions: read
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
Expand Down Expand Up @@ -59,10 +60,35 @@ 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: |
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"
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[@]}"
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with:
path: dist
retention-days: 30

deploy:
needs: build
Expand Down
31 changes: 31 additions & 0 deletions scripts/preserve-page-assets.mjs
Original file line number Diff line number Diff line change
@@ -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)))
}
39 changes: 39 additions & 0 deletions tests/page-assets.test.mjs
Original file line number Diff line number Diff line change
@@ -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' })
})
83 changes: 83 additions & 0 deletions tests/pages-deploy-assets.test.mjs
Original file line number Diff line number Diff line change
@@ -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/)
})
Loading