ci(pages): [CI-02] validate staged artifacts and preserve legacy demo paths

This commit is contained in:
Harvey Zhao committed 2026-09-14 16:43:33 +08:00
1 parent b1e0b976b0
commit 72d2b9c36b
20 files changed
+6120 -13

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
# Pages artifact preparation
`yarn prepare:pages` runs after `yarn ci:build`. It never changes `docs/`, a
distribution, a Git ref or remote settings. It creates a fresh
`refactor/.cache/pages/run-*/site` and prints that exact directory as the Pages
step output. `latest.json` points only to the last successfully validated build;
a failed preparation must stop the workflow, not reuse an earlier output.
- `contract.json` preserves the domain and selected historical HTML, language,
editor, compiled, i18n and smoke routes. It is a route contract, not an exhaustive
HTML/CSS/JavaScript link crawler or external-service verification.
- `artifact.ts` inventories ordinary files, rejects symbolic/hard links and path
escapes, verifies the domain/required files, and compares all 63 main/legacy/ESM
compiled entries against the corresponding 21 package distributions.
- `prepare.ts` copies the checked tree, then rebuilds all 21 historical
`uncompiled/<package>/index.js` URLs with the same IIFE/development configuration
as `yarn dev`. This avoids publishing checked-in dev output from an older source.
Package source/build/lock inputs and every staged file are fingerprinted.
- `../prepare-pages.mjs` is the argument-free CLI and GitHub output adapter.
The output does not promise that the input `dist` was freshly built: the workflow
must run `ci:build` first. Local users follow the same sequence. Existing static
vendor assets are copied; provenance and full editor validation remain SITE/REL
gates. No package-manager or dependency change is required.
`yarn test:pages` covers missing/empty routes, changed CNAME, links/path escape,
stale/missing distribution output and deployment bypasses. `yarn test:pages:browser`
serves staged core/chapter demo and ESM entries to Chromium/Firefox/WebKit. A
controlled page and same-origin native HTTP/Range MP4 exercise playback, chapter
update, web fullscreen and destroy. It records exact script hashes and network
failures separately from JavaScript errors; media cancellation is not automatically
a script-load failure. This is not a full real-editor, SDK or physical-device test.
The deploy workflow remains manual, restricted to master with
`PAGES_DEPLOY_ENABLED=true`, and depends on every reusable CI job. Its sole deploy
step accepts the current run's official Pages artifact. No PR artifact download,
arbitrary artifact ID, lifecycle rebuild or Git push is introduced. The checks job
has read-only contents permissions; only deployment receives Pages/OIDC writes.
Failed staging builds keep their report once the output directory exists. Earlier
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.
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
export interface PagesContract { domain: string, requiredPaths: string[] }
export interface FileIdentity { bytes: number, sha256: string }
export function digest(file: string): FileIdentity {
const bytes = fs.readFileSync(file)
return { bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }
}
export function inside(root: string, relative: string): string {
assert(relative && !relative.includes('\\') && !path.isAbsolute(relative), `Invalid artifact path: ${relative}`)
const result = path.resolve(root, relative)
const actual = path.relative(path.resolve(root), result)
assert(actual && actual !== '..' && !actual.startsWith(`..${path.sep}`) && !path.isAbsolute(actual), `Artifact path escaped root: ${relative}`)
return result
}
export function inventory(root: string): Record<string, FileIdentity> {
assert(fs.lstatSync(root).isDirectory() && !fs.lstatSync(root).isSymbolicLink(), 'Artifact root must be a real directory')
const files: Record<string, FileIdentity> = {}
function walk(directory: string) {
for (const name of fs.readdirSync(directory).sort()) {
const file = path.join(directory, name)
const stat = fs.lstatSync(file)
assert(!stat.isSymbolicLink(), `Pages cannot contain symbolic links: ${file}`)
if (stat.isDirectory()) {
walk(file)
}
else {
assert(stat.isFile() && stat.nlink === 1, `Pages requires ordinary files without hard links: ${file}`)
const relative = path.relative(root, file).split(path.sep).join('/')
files[relative] = digest(file)
}
}
}
walk(root)
assert(Object.values(files).reduce((total, file) => total + file.bytes, 0) < 10_000_000_000, 'Pages artifact exceeds the 10GB archive ceiling')
return files
}
export function validateArtifact(root: string, contract: PagesContract) {
const files = inventory(root)
for (const relative of contract.requiredPaths) {
inside(root, relative)
assert(Object.hasOwn(files, relative), `Missing required Pages route: ${relative}`)
assert(relative === '.nojekyll' || files[relative]!.bytes > 0, `Empty Pages route: ${relative}`)
}
assert.equal(fs.readFileSync(inside(root, 'CNAME'), 'utf8').trim(), contract.domain, 'Pages domain changed')
return files
}
export function compareCompiled(root: string, site: string, names: string[]) {
const checked: string[] = []
for (const name of names) {
for (const suffix of ['.js', '.legacy.js', '.mjs']) {
const relative = `${name}${suffix}`
const source = inside(root, `packages/${name}/dist/${relative}`)
const target = inside(site, `compiled/${relative}`)
assert.equal(digest(target).sha256, digest(source).sha256, `Stale compiled Pages artifact: ${relative}`)
checked.push(relative)
}
}
return checked
}
+32
View File
@@ -0,0 +1,32 @@
{
"domain": "github.artplayer.org",
"requiredPaths": [
"index.html",
"mobile.html",
"esm.html",
"iframe.html",
"i18n.html",
".nojekyll",
"CNAME",
"ads.txt",
"googlebcdef0d1e9238554.html",
"document/index.html",
"document/en/index.html",
"document/start/option.html",
"document/en/start/option.html",
"test/index.html",
"test/test.js",
"assets/js/bootstrap.js",
"assets/js/common.js",
"assets/js/mobile.js",
"compiled/artplayer.js",
"compiled/artplayer.legacy.js",
"compiled/artplayer.mjs",
"compiled/i18n/zh-tw.js",
"compiled/i18n/id.mjs",
"uncompiled/artplayer/index.js",
"uncompiled/artplayer-tool-iframe/index.js",
"llms.txt",
"llms.manifest.json"
]
}
+46
View File
@@ -0,0 +1,46 @@
import type { PagesContract } from './artifact.ts'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { build } from 'vite'
import { getViteBuildConfig } from '../library/config.ts'
import { getGlobalName } from '../library/names.ts'
import { getEntryFile, getProjects } from '../library/projects.ts'
import { compareCompiled, digest, inside, inventory, validateArtifact } from './artifact.ts'
export async function preparePages(root: string) {
const contract: PagesContract = JSON.parse(fs.readFileSync(path.join(root, 'scripts/pages/contract.json'), 'utf8'))
const projects = getProjects(root)
const names = Object.keys(projects).sort()
const docs = path.join(root, 'docs')
// Validate before copying, so redirected input paths cannot be followed by cp.
inventory(docs)
const compiled = compareCompiled(root, docs, names)
const cache = path.join(root, 'refactor/.cache/pages')
fs.mkdirSync(cache, { recursive: true })
const output = fs.mkdtempSync(path.join(cache, 'run-'))
const site = inside(output, 'site')
fs.cpSync(docs, site, { recursive: true })
const inputs = Object.fromEntries(names.map(name => [name, { manifest: digest(path.join(projects[name]!, 'package.json')), source: inventory(path.join(projects[name]!, 'src')) }]))
const report = { source: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), inputs, buildInputs: { library: inventory(path.join(root, 'scripts/library')), pages: inventory(path.join(root, 'scripts/pages')), lock: digest(path.join(root, 'yarn.lock')) }, output, compiled, contract: digest(path.join(root, 'scripts/pages/contract.json')), aliases: [] as string[], status: 'building', error: '', files: {} as ReturnType<typeof inventory> }
try {
for (const name of names) {
// Preserve old demo URLs with fresh builds using the same configuration as yarn dev.
const outDir = inside(site, `uncompiled/${name}`)
await build({ root: projects[name], ...getViteBuildConfig({ entry: getEntryFile(projects[name]!), outDir, name: getGlobalName(name), format: 'iife', fileName: 'index.js', minify: false, emptyOutDir: true }) })
report.aliases.push(`uncompiled/${name}/index.js`)
}
report.files = validateArtifact(site, { ...contract, requiredPaths: [...contract.requiredPaths, ...report.aliases] })
report.status = 'validated'
fs.writeFileSync(path.join(cache, 'latest.json'), `${JSON.stringify({ output }, null, 2)}\n`)
return { output, site, report }
}
catch (error) {
report.status = 'failed'
report.error = String(error)
throw error
}
finally {
fs.writeFileSync(path.join(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`)
}
}
+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { preparePages } from './pages/prepare.ts'
assert.equal(process.argv.length, 2, 'Use yarn prepare:pages without arguments')
const result = await preparePages(process.cwd())
if (process.env.GITHUB_OUTPUT)
fs.appendFileSync(process.env.GITHUB_OUTPUT, `site=${result.site}\n`)
console.log(`Pages artifact validated: ${result.site}; ${Object.keys(result.report.files).length} files. No deployment was performed.`)
+1 -1
View File
@@ -6,5 +6,5 @@
"allowImportingTsExtensions": true,
"checkJs": true
},
"include": ["library/**/*.ts", "build.js", "dev.js", "utils.js", "projects.js", "rebuild.js", "build-analysis.mjs"]
"include": ["library/**/*.ts", "pages/**/*.ts", "prepare-pages.mjs", "build.js", "dev.js", "utils.js", "projects.js", "rebuild.js", "build-analysis.mjs"]
}