test(release): [REL-04] checkpoint exact historical Pages recovery

This commit is contained in:
Harvey Zhao committed 2026-09-15 00:43:31 +08:00
1 parent d0c33f6258
commit ddf3d6ccbc
17 files changed
+3460 -12

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { readJson, workspace, writeJson } from './package-consumer.mjs'
import { digest, inside } from './pages/artifact.ts'
import { replaceRehearsalSite, verifyRestoredTree } from './pages/recovery.ts'
assert.equal(process.versions.node, fs.readFileSync(path.join(workspace, '.node-version'), 'utf8').trim(), 'Use pinned Node')
const baseline = readJson(path.join(workspace, 'refactor/baselines/pages-artifact-validation.json'))
const archived = baseline.remote.rollbackArchive
assert(/^[a-f0-9]{40}$/.test(archived.commit))
const previousArchive = inside(workspace, archived.path)
assert.deepEqual(digest(previousArchive), { bytes: archived.bytes, sha256: archived.sha256 }, 'Frozen Pages archive changed')
const git = args => execFileSync('git', args, { cwd: workspace, encoding: 'utf8', windowsHide: true })
assert.equal(git(['rev-parse', '--show-object-format']).trim(), 'sha1', 'Recovery verifier expects Git SHA-1 object IDs')
const tree = git(['ls-tree', '-r', '-l', '-z', archived.commit]).split('\0').filter(Boolean)
const expected = {}
for (const entry of tree) {
const match = /^(100644|100755) blob ([a-f0-9]{40}) +(\d+)\t(.+)$/s.exec(entry)
assert(match, 'Site snapshot contains unsupported Git entries')
const [, , objectId, bytes, relative] = match
inside(workspace, relative)
assert(!Object.hasOwn(expected, relative), 'Duplicate Git path')
expected[relative] = { objectId, bytes: Number(bytes) }
}
const parent = path.join(workspace, 'refactor/.cache/pages-recovery')
fs.mkdirSync(parent, { recursive: true })
const output = fs.mkdtempSync(path.join(parent, 'run-'))
const report = { task: 'REL-04', capturedAt: new Date().toISOString(), source: git(['rev-parse', 'HEAD']).trim(), node: process.versions.node, passed: false, archived, gitFiles: expected, scope: 'Local restoration of the complete frozen gh-pages snapshot; no deployment or remote setting changes' }
try {
const archive = path.join(output, 'canonical.zip')
const archiveArgs = ['-c', 'core.autocrlf=false', '-c', 'core.eol=lf', 'archive', '--format=zip', `--output=${archive}`, archived.commit]
git(archiveArgs)
report.canonicalArchive = { command: ['git', ...archiveArgs], ...digest(archive) }
const members = execFileSync('tar', ['-tf', archive], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)
assert.equal(new Set(members).size, members.length)
for (const member of members) inside(workspace, member.replace(/\/$/, ''))
assert.deepEqual(members.filter(member => !member.endsWith('/')).sort(), Object.keys(expected).sort(), 'Archive inventory differs from frozen Git tree')
const original = path.join(output, 'original')
const site = path.join(output, 'site')
const prepared = path.join(output, 'prepared')
fs.mkdirSync(original)
execFileSync('tar', ['-xf', archive, '-C', original], { windowsHide: true })
const before = verifyRestoredTree(original, expected)
assert.equal(fs.readFileSync(path.join(original, 'CNAME'), 'utf8').trim(), 'github.artplayer.org')
fs.cpSync(original, site, { recursive: true })
fs.writeFileSync(path.join(site, 'compiled/artplayer.js'), 'broken candidate fixture\n')
fs.writeFileSync(path.join(site, 'CNAME'), 'recovery-fixture.invalid\n')
fs.unlinkSync(path.join(site, 'iframe.html'))
fs.writeFileSync(path.join(site, 'candidate-only.txt'), 'must disappear after restoration\n')
assert.throws(() => verifyRestoredTree(site, expected), /missing or additional files/)
fs.cpSync(original, prepared, { recursive: true })
const restored = replaceRehearsalSite(output, expected)
assert.deepEqual(restored, before)
assert.equal(fs.readFileSync(path.join(site, 'CNAME'), 'utf8').trim(), 'github.artplayer.org')
assert.equal(fs.readFileSync(path.join(output, 'failed-site/compiled/artplayer.js'), 'utf8'), 'broken candidate fixture\n')
assert(!fs.existsSync(path.join(site, 'candidate-only.txt')))
report.files = restored
report.simulatedChanges = ['modified compiled/artplayer.js', 'changed CNAME', 'deleted iframe.html', 'added candidate-only.txt']
report.failedSnapshotRetained = true
report.passed = true
}
catch (error) {
report.error = { name: error.name, message: error.message }
throw error
}
finally {
writeJson(path.join(output, 'report.json'), report)
writeJson(path.join(parent, 'latest.json'), { output: path.relative(workspace, output).replaceAll('\\', '/'), passed: report.passed })
console.log(`Pages recovery: ${path.relative(workspace, output)}; passed=${report.passed}`)
}
+24
View File
@@ -41,3 +41,27 @@ Failed staging builds keep their report once the output directory exists. Earlie
preflight failures stay in the CI log. Reports are uploaded even on failure; staged
files are uploaded for deployment only after preparation and browser checks pass.
See `refactor/pages-deployment.md` for remote state, activation and recovery.
## Complete historical snapshot recovery
`yarn test:rollback:pages` validates the CI-02 saved ZIP identity and fixed
gh-pages Git objects, creates a new ZIP with command-local `core.autocrlf=false`
and `core.eol=lf`, and verifies all extracted paths, sizes and Git blob IDs.
The initial ZIP used Windows line conversion; its 403 changed text files remain
recorded as a failed exact-Git-byte recovery, not silently normalized.
`recovery.ts` rejects stale or extra files before replacement. The command
damages only an isolated copy, restores it from a fully validated prepared
directory, and keeps the failed copy. All outputs stay in
`refactor/.cache/pages-recovery/run-*`; there is no recursive cleanup or remote
mutation. Both the saved CI-02 ZIP and its Git commit must be available before
this explicit rehearsal can run. Preserve the canonical ZIP with its report for
future recovery; publishing a new candidate requires a fresh pre-upgrade snapshot.
`yarn test:rollback:pages:browser` accepts the latest successful recovery only,
rechecks every restored Git blob, then serves original HTML without dev reload
injection. It verifies 12 route bodies and runs six UMD/ESM playback cases in
Chromium/Firefox/WebKit using restored scripts and media. Aborted transfers are
retained separately only for the exact sample media path and native aborted
request condition. This is local snapshot playback evidence, not a complete
editor, iframe, Thumbnail extraction, external SDK or remote deployment check.
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import { inside, inventory } from './artifact.ts'
export interface GitFile { objectId: string, bytes: number }
export function verifyRestoredTree(root: string, expected: Record<string, GitFile>) {
const files = inventory(root)
assert.deepEqual(Object.keys(files).sort(), Object.keys(expected).sort(), 'Restored site has missing or additional files')
for (const [relative, identity] of Object.entries(expected)) {
const bytes = fs.readFileSync(inside(root, relative))
assert.equal(bytes.length, identity.bytes, `Restored file size differs: ${relative}`)
const objectId = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
assert.equal(objectId, identity.objectId, `Restored Git blob differs: ${relative}`)
}
return files
}
export function replaceRehearsalSite(run: string, expected: Record<string, GitFile>) {
const root = fs.realpathSync(run)
const live = inside(root, 'site')
const prepared = inside(root, 'prepared')
const failed = inside(root, 'failed-site')
assert(!fs.existsSync(failed), 'Previous failed snapshot must not be overwritten')
for (const directory of [live, prepared]) {
assert.equal(fs.realpathSync(directory), directory, 'Rehearsal directories must not be links')
assert(fs.lstatSync(directory).isDirectory())
}
verifyRestoredTree(prepared, expected)
fs.renameSync(live, failed)
try {
fs.renameSync(prepared, live)
}
catch (error) {
fs.renameSync(failed, live)
throw error
}
return verifyRestoredTree(live, expected)
}