mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-17 10:01:20 -08:00
ci(pages): [CI-02] validate staged artifacts and preserve legacy demo paths
This commit is contained in:
1 parent
b1e0b976b0
commit
72d2b9c36b
20 files changed
+6120
-13
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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.`)
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user