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

+15 -2
View File
@@ -215,6 +215,16 @@ jobs:
run: yarn ci:check 2>&1 | tee refactor/.cache/ci/check.log
- name: Build libraries and documentation
run: yarn ci:build 2>&1 | tee refactor/.cache/ci/build.log
- name: Validate and stage Pages paths
id: pages
if: inputs.pages-artifact && github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest'
run: yarn prepare:pages 2>&1 | tee refactor/.cache/ci/pages.log
- name: Install staged Pages browser dependencies
if: inputs.pages-artifact && github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest'
run: yarn test:browser:install --with-deps 2>&1 | tee refactor/.cache/ci/pages-browser-install.log
- name: Verify staged Pages entrypoints
if: inputs.pages-artifact && github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest'
run: yarn test:pages:browser 2>&1 | tee refactor/.cache/ci/pages-browser.log
- name: Record source and tool versions
if: always()
run: |
@@ -229,7 +239,10 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: checks-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
path: refactor/.cache/ci/
path: |
refactor/.cache/ci/
refactor/.cache/pages/run-*/report.json
refactor/.cache/pages/run-*/browser.json
include-hidden-files: true
retention-days: 14
if-no-files-found: warn
@@ -237,7 +250,7 @@ jobs:
if: inputs.pages-artifact && github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: docs
path: ${{ steps.pages.outputs.site }}
include-hidden-files: true
strategy:
fail-fast: false
+8 -5
View File
@@ -40,12 +40,12 @@
"test:dash-control": "node --test test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js",
"dev": "npx cross-env NODE_ENV=development node ./scripts/dev.js",
"build": "npx cross-env NODE_ENV=production node ./scripts/build.js",
"lint": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix",
"lint": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library,pages}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix",
"build:all": "yarn ci:build && yarn lint",
"check:toolchain": "node scripts/check-toolchain.mjs",
"lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix",
"lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library,pages}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix",
"check:plan": "node refactor/scripts/plan.mjs --check",
"test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js test/ci-summary.test.js test/package-runtime.test.js test/library-build.test.js test/site-loading.test.js test/documentation-pipeline.test.js test/site-build.test.js test/site-markdown.test.js test/site-editor.test.js test/plugin-scaffold.test.js test/dev-server.test.js",
"test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js test/ci-summary.test.js test/package-runtime.test.js test/library-build.test.js test/site-loading.test.js test/documentation-pipeline.test.js test/site-build.test.js test/site-markdown.test.js test/site-editor.test.js test/plugin-scaffold.test.js test/dev-server.test.js test/pages-artifact.test.js",
"test:baseline": "node --test refactor/scripts/*.test.mjs",
"ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --report && yarn check:ci && yarn test:contracts && yarn check:contracts --report && yarn check:plan && yarn check:release-ledger && yarn lint && yarn typecheck:library && yarn typecheck:scaffold && yarn typecheck:docs-tools && yarn check:docs-smoke && yarn check:editor-types && yarn check:llm && yarn typecheck:site-assets && yarn check:site-assets && yarn check:types && yarn typecheck && yarn typecheck:react && yarn lint:react && yarn typecheck:vue && yarn lint:vue && yarn test",
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:llm && yarn build:test && yarn build:docs && yarn test:imports",
@@ -94,7 +94,7 @@
"test:jassub-types": "node --test refactor/scripts/jassub-types.test.mjs",
"test:jassub-types-package": "node refactor/scripts/jassub-package-types.mjs",
"check:ci": "node refactor/scripts/ci-workflow.mjs",
"test:ci": "node --test test/ci-summary.test.js refactor/scripts/ci-workflow.test.mjs refactor/scripts/impact.test.mjs test/package-runtime.test.js",
"test:ci": "node --test test/ci-summary.test.js refactor/scripts/ci-workflow.test.mjs refactor/scripts/impact.test.mjs test/package-runtime.test.js test/pages-artifact.test.js",
"test:package:runtime": "node scripts/package-runtime.mjs --canonical",
"test:asr": "node --test test/asr.test.js test/asr-audio.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-distribution.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js",
"test:asr-types-package": "node refactor/scripts/asr-package-types.mjs",
@@ -137,7 +137,10 @@
"check:release-ledger": "node refactor/scripts/release-ledger.mjs",
"report:release-ledger": "node refactor/scripts/release-ledger.mjs --report",
"release:preflight": "yarn check:toolchain --strict && node refactor/scripts/release-ledger.mjs --report --strict",
"test:release-ledger": "node --test refactor/scripts/release-ledger.test.mjs"
"test:release-ledger": "node --test refactor/scripts/release-ledger.test.mjs",
"prepare:pages": "node scripts/prepare-pages.mjs",
"test:pages": "node --test test/pages-artifact.test.js",
"test:pages:browser": "node test/pages-browser.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
+1
View File
@@ -34,6 +34,7 @@
| [全包大版本策略](version-policy.md) | 每包分别升级一个 major 的目标清单、兼容要求和版本落地步骤 |
| [已实现的 CI 入口](ci-setup.md) | Yarn 检查/构建、只读 lint、Pages 隔离及远端待验收状态 |
| [GitHub CI/CD](github-ci-cd.md) | PR/兼容矩阵、构建报告、Pages、npm 发布及远端准入验证 |
| [Pages部署与恢复](pages-deployment.md) | 独立暂存、旧URL/域名预检、只读远端快照与人工切换恢复顺序 |
| [AI 协作流程](ai-workflow.md) | AI 接续工作、任务边界、验证、记录和交接模板 |
| [每任务提交审计](commit-audit.md) | 实际Git状态迁移、独立提交、初始例外、分支合并和CI报告 |
| [全包影响映射](impact-analysis.md) | 变更范围、依赖/验证关系、必需CI命令及尚缺的包验证 |
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,36 @@
# CI-02 Pages产物预检与独立部署
生产构建只更新compiled,而现有移动页、iframe和编辑器示例继续使用uncompiled。
直接上传docs可能保留旧开发产物。本次在独立暂存目录按现有开发配置构建21个旧入口,
保持旧URL/全局名称/开发模式,工作区docs和包产物不被改写。没有更换播放器API、版本或依赖。
新增pages/artifact.ts负责文件、路径、域名和compiled一致性,prepare.ts负责暂存和
调用现有library构建模块,入口CLI只处理命令/输出。包源码、构建输入和完整站点输出
都有指纹。CNAME/.nojekyll、旧HTML、语言/编辑器/i18n入口必须存在;规则不声称覆盖
所有动态外部链接。test:pages及test:pages:browser加入package scripts,普通Node测试
运行文件/工作流回归,浏览器验证仅在显式命令或受信任Pages准备分支中执行。
工作流先build,再prepare,再安装浏览器和执行暂存入口检查,最后上传准确site路径。
部署继续等待全部CI作业成功,保留master/启用变量/manual/environment限制;仅deploy
有Pages/OIDC写权限。不增加远端操作,失败不上传用于部署的站点,但保留诊断报告。
## 实际验证
- 21个入口构建成功,63项compiled/dist比较一致,暂存546个文件;正常目录与包源码未改。
- 55项CI/文件/权限回归通过;严格library TS、定向lint与actionlint1.7.12通过。
- 同一暂存目录的demo/ESM两种入口,Windows Chromium153.0.8010.12、Firefox155.0、
WebKit26.6共6项实际播放、章节更新、网页全屏和销毁检查通过。
- 首轮拦截MP4完整响应使WebKit两项超时;第二轮跨端口原生HTTP使Chromium两项媒体
ERR_FAILED,其余通过。最终使用同源HTTP/Range。没有确定跨端口错误的浏览器内部根因。
- 同源首轮全部走完播放,但Chromium两项把媒体ERR_ABORTED并入脚本错误断言而失败。
修正为记录网络事件、检查脚本加载错误/JS异常以及播放时的video.error。保留原失败报告,
没有为掩盖产品错误增加等待或关闭浏览器;首轮默认30秒,最终显式10秒。
- Pages API、分支/环境只读查询和7个线上HEAD200留档;部署变量返回404、启用状态未知。
旧gh-pages提交的本地ZIP已保存;没有修改Pages source或发布站点。
证据见[机器记录](../baselines/pages-artifact-validation.json),维护地图和恢复流程见
[部署说明](../pages-deployment.md)。CI-02只完成本地实现;CI-04仍负责真实远端run、
环境迁移、部署后探测及故障演练。站点/插件/设备/许可和发布复盘未自动完成。
回退本提交恢复原工作流上传docs的配置,移除新脚本/测试/状态;远端设置不会随Git回退,
必须按部署说明单独处理。不push、不publish,也不执行远端恢复写入。
+7
View File
@@ -97,6 +97,13 @@ CORE-25 已修复无 navigator 默认选项缺陷,严格 test:package:release
## Pages 隔离与启用条件
CI-02已实现独立暂存与旧路径预检,并验证21个重新构建的uncompiled入口、63个
compiled/dist匹配和6项三浏览器暂存播放检查。上传路径为prepare:pages的精确输出,
不再直接上传工作区docs;新增脚本和回退说明见[Pages部署](pages-deployment.md)。
2026-09-14只读API确认线上仍为gh-pages根目录的legacy部署,域名github.artplayer.org;
环境允许master/gh-pages,启用变量查询404,状态未知。后面的初始记录保留为历史背景,
其“远端未知”描述不代表本次没有读取配置;CI-04实际部署/切换仍未验证。
ENG-07 将浏览器候选改为 `yarn test:package` 实际打包并安装后的文件,使用输出映射禁止
回退到源码。安装/构建日志、tarball、成员指纹和浏览器结果一并上传,构建快照的 node_modules
链接不上传。初始包范围为 core/chapter;PKG-CHAPTER-04 已将这两个包的已知类型诊断清零,
+4
View File
@@ -10,6 +10,10 @@
## 目标流水线
CI-02的本地Pages产物链已实现:build后在独立目录更新全部旧开发入口,验证路径/
域名/分发一致性并检查暂存播放,然后上传准确输出。只读远端配置快照、迁移与恢复
顺序见[Pages记录](pages-deployment.md);实际部署仍待CI-04和明确授权。
| 流程 | 触发和范围 | 交付/门槛 |
| --- | --- | --- |
| PR 和主线 CI | PR、主线及重构分支的相关提交;支持人工重跑 | 固定安装、只读 lint、类型、单元/生命周期、影响范围内的浏览器和产物测试、计划/文档检查 |
+59
View File
@@ -0,0 +1,59 @@
# Pages 迁移、验收与恢复
CI-02交付本地配置与预检;CI-04负责实际远端运行和配置切换。当前没有部署、推送、
修改仓库变量或调整Pages source。完整站点内容、外部SDK和三轮发布复盘门槛仍适用。
## 2026-09-14只读快照
- Pages API:`build_type=legacy`,source为`gh-pages`、根目录,status=built。
- CNAME与html_url:`github.artplayer.org`、`https://github.artplayer.org/`。
HTTPS enforced,证书state=approved;这些是API返回值,不是独立TLS/DNS审计。
- gh-pages提交:`a6d2b81d05c68c8c78cbe62ca0b06761c983f8b8`。本地对象已存在,
已用git archive保存ZIP恢复副本,路径/内容哈希见CI-02机器记录。
- github-pages环境为custom branch policies,读取到master与gh-pages两个branch规则。
protection_rules只有branch_policy;未据此新增或删除规则。
- PAGES_DEPLOY_ENABLED查询返回404:不能据此证明变量不存在或权限完整;启用状态未确认。
- 对根页、mobile.html、iframe.html、esm.html、document/、document/en/、
compiled/artplayer.js的HTTPS HEAD均返回200。未执行线上播放器或广告交互。
以上是时间点快照。发布前再次只读查询,核对源码SHA、旧分支、域名、环境保护和变量,
不能把它们写成永久配置或实际Actions部署已成功。
## 本地与CI产物链
1. 使用.node-version与Yarn1.22.22,frozen安装,执行ci:check和ci:build。
2. prepare:pages检查compiled与dist一致,将docs复制到独立cache目录,然后正常构建
21个旧uncompiled入口。保留CNAME/.nojekyll、旧HTML与两种语言的文档入口。
3. test:pages:browser检查暂存目录的demo/ESM入口。全部所需CI作业成功后,官方
upload-pages-artifact只上传本次步骤输出的site,deploy-pages在github-pages环境执行。
4. 报告记录source、来源文件/锁/构建脚本、63个分发入口及完整暂存文件指纹;没有
用工作区docs路径替代暂存目录,也没有从外部workflow run下载同名artifact。
脚本边界和命令见[scripts/pages](../scripts/pages/README.md)。GitHub对Pages artifact
要求无符号/硬链接,部署需要对应权限、environment及构建依赖,见
[官方自定义工作流说明](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages)。
当前固定Actions的SHA沿用已验证配置,未升级依赖。
## 获得实际部署授权后的切换顺序
先确认完整发布门槛和目标master源码,再重新保存当前gh-pages提交及完整旧目录。
核对github-pages只允许受信任分支及必要审批,把Pages source切换为GitHub Actions。
确认自定义域名/DNS/TLS保持原值后,设置PAGES_DEPLOY_ENABLED=true并手动运行Deploy Pages。
记录run ID/attempt/SHA、预检报告哈希、artifact与部署URL;不要只根据workflow名称信任产物。
部署后逐项GET验证contract.json的旧路径、MIME和关键文本,再在真实页面验证桌面/
移动编辑器、中文/英文文档、ESM、iframe及播放/章节。HEAD200只证明可访问,不能代替
这些交互。检查被重写或丢失的路径,以及compiled/uncompiled确实来自批准的候选。
## 恢复
发生故障先禁用启用变量,避免新部署进入队列;不要取消已经开始的写入或自动force push。
保存失败run/部署/请求证据,并重新读取当前source和分支SHA。
如果旧gh-pages仍指向已保存的提交,经授权将Pages source恢复为该分支根目录,保留
原域名/HTTPS并等待Pages完成构建,然后重复旧路径和交互验证。如果分支已被其他人
修改,不能直接移动它:先核对保存ZIP的哈希与Git提交内容,准备明确的恢复提交/操作
供批准。恢复本次仓库代码可以revert CI-02提交,但revert不会自行恢复远端设置。
旧自动force-push工作流不是默认恢复方案。任何再次构建、内容调整或候选替换都要重跑
适用检查;不能把之前的绿灯套用到新的站点字节。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 255 项,范围 22 个包及工作区/示例。
状态:todo 53 / doing 17 / blocked 0 / done 185 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 52 / doing 17 / blocked 0 / done 186 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -94,7 +94,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| CI-01 | workspace<br>增强兼容矩阵、并发缓存与 CI 报告 | DOC-10, ENG-08, ENG-09, ENG-10 | OS/Node/TS/浏览器与影响范围矩阵、缓存、超时、汇总检查和 artifact 报告 | 固定安装、失败/取消不误报、核心影响全生态;检查只读,失败证据可追溯 | H | doing |
| CI-02 | workspace<br>分离并改进 GitHub Pages 部署 | DOC-10, ENG-02, SITE-03 | Pages artifact 部署配置、旧路径/域名核对、预检和迁移恢复指南 | 部署只取受信任已验证产物;本地实现可验收,远端 source/环境和实际部署状态单独登记 | H | todo |
| CI-02 | workspace<br>分离并改进 GitHub Pages 部署 | DOC-10, ENG-02, SITE-03 | Pages artifact 部署配置、旧路径/域名核对、预检和迁移恢复指南 | 部署只取受信任已验证产物;本地实现可验收,远端 source/环境和实际部署状态单独登记 | H | done |
| CI-03 | workspace<br>建立 npm 分包候选与发布工作流 | DOC-10, CI-01, REL-08, REL-04 | 候选准备、精确 artifact 发布配置、OIDC 评估、版本/tag/registry 预检和部分失败恢复 | 不自动发布;明确逐包信任前置和 dry run 限制,不能重建未验证内容或重发冲突版本;按版本清单校验各包下一 major 和预发布/正式 tag,保留旧核心支持范围 | H | todo |
| CI-04 | workspace<br>验收 GitHub 流水线与远端发布准入 | CI-01, CI-02, CI-03, SITE-06 | 静态/干净环境检查、真实 PR 正反例、候选 dry run、required checks/Pages/npm 必需配置状态及运维指南 | 必要 Actions 证据和远端配置核对齐全;缺失保持未完成,真实 publish/deploy 仍在授权发布步骤执行 | H | todo |
@@ -497,6 +497,7 @@
- ENG-10: [记录](changes/2026-09-10-ENG-10-test-reliability.md) [记录](test-reliability.md)
- ENG-11: [记录](build-analysis.md) [记录](baselines/bundle-attribution.json) [记录](changes/2026-09-11-ENG-11-build-analysis.md)
- CI-01: [记录](ci-setup.md) [记录](changes/2026-09-13-CI-01-matrix-summary.md) [记录](baselines/ci-matrix-validation.json) [记录](changes/2026-09-13-CI-01-node-consumers.md) [记录](baselines/node-consumer-validation.json)
- CI-02: [记录](changes/2026-09-14-CI-02-pages-artifact.md) [记录](baselines/pages-artifact-validation.json) [记录](pages-deployment.md)
- PILOT-01: [记录](changes/2026-09-10-PILOT-01-chapter.md) [记录](baselines/pilot-validation.json)
- CORE-01: [记录](changes/2026-09-10-CORE-01-typed-utils.md) [记录](baselines/core-utils-validation.json)
- CORE-02: [记录](changes/2026-09-11-CORE-02-typed-emitter.md) [记录](baselines/emitter-validation.json)
+14
View File
@@ -1,5 +1,19 @@
# 进度与证据
## CI-02 Pages产物预检与恢复流程完成
生产构建后的独立暂存目录重新生成21个旧uncompiled入口,验证63个compiled/dist
匹配、域名/隐藏文件/旧路径,登记546个文件指纹;工作流只上传该步骤输出,且必须
经过暂存入口三浏览器验证和全部CI作业。55项CI回归、严格TS、lint/actionlint通过,
最终6项demo/ESM原生播放、章节更新、网页全屏和销毁检查通过。前几轮媒体服务/
网络错误断言导致的失败全部留档,不改变产品、不放宽候选断言来掩盖故障。
只读API确认当前线上仍是gh-pages分支根目录legacy部署,域名github.artplayer.org;
环境允许master/gh-pages,启用变量404仍未知。七个关键路径HEAD200,旧提交ZIP
已保存。没有远端写入、部署、push或publish。见[变更](changes/2026-09-14-CI-02-pages-artifact.md)、
[证据](baselines/pages-artifact-validation.json)与[恢复说明](pages-deployment.md)。
255项:186 done、17 doing、52 todo。CI-04保留实际切换/远端运行/发布后验收,插件、
站点、设备及复盘门槛未关闭。Chapter桌面记录已核对,时序疑点和真机缺口继续保持doing。
## MOD-03 核心热路径测量与优化完成
完成检查点23a4f22b7后的剩余验收:原生存储三引擎三组旧新9项、安装包布局/设置
+35 -1
View File
@@ -92,8 +92,42 @@ export function validateCIWorkflow(source) {
assert(browser.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') && step.if === 'always()' && step.with.path.split('\n').includes('refactor/.cache/vue-consumer-*/')), 'Retain Vue consumer failure evidence')
const pages = workflow.jobs.checks.steps.find(step => step.uses?.startsWith('actions/upload-pages-artifact@'))
assert.equal(pages?.if, 'inputs.pages-artifact && github.ref == \'refs/heads/master\' && matrix.os == \'ubuntu-latest\'', 'Only one trusted matrix leg can prepare Pages')
const prepareIndex = workflow.jobs.checks.steps.findIndex(step => step.id === 'pages')
const prepare = workflow.jobs.checks.steps[prepareIndex]
assert(prepare && prepare.if === pages.if && prepare.run === 'yarn prepare:pages 2>&1 | tee refactor/.cache/ci/pages.log', 'Pages upload needs the matching strict preparation step')
assert(prepareIndex > workflow.jobs.checks.steps.findIndex(step => step.run?.startsWith('yarn ci:build ')) && prepareIndex < workflow.jobs.checks.steps.indexOf(pages), 'Build, preflight and upload must be ordered')
assert.equal(pages.with.path, '${{ steps.pages.outputs.site }}', 'Upload the exact validated site, never the working docs tree')
let previous = prepareIndex
for (const command of ['yarn test:browser:install --with-deps 2>&1 | tee refactor/.cache/ci/pages-browser-install.log', 'yarn test:pages:browser 2>&1 | tee refactor/.cache/ci/pages-browser.log']) {
const index = workflow.jobs.checks.steps.findIndex(step => step.run === command)
assert(index > previous && index < workflow.jobs.checks.steps.indexOf(pages) && workflow.jobs.checks.steps[index].if === pages.if, 'Staged entrypoints require ordered browser installation and verification before upload')
previous = index
}
return { jobs: requiredJobs, systems, summary: summary.name }
}
export function validatePagesWorkflow(source) {
const workflow = YAML.parse(source, { uniqueKeys: true })
assert.deepEqual(Object.keys(workflow.on), ['workflow_dispatch'], 'Pages needs an explicit trusted dispatch')
assert.deepEqual(workflow.permissions, { contents: 'read' })
assert.deepEqual(workflow.concurrency, { 'group': 'pages', 'cancel-in-progress': false })
assert.deepEqual(Object.keys(workflow.jobs).sort(), ['deploy', 'validate'])
const { validate, deploy } = workflow.jobs
for (const job of [validate, deploy]) {
assert.equal(job.if, 'github.ref == \'refs/heads/master\' && vars.PAGES_DEPLOY_ENABLED == \'true\'')
assert(!job['continue-on-error'])
}
assert.equal(validate.uses, './.github/workflows/nodejs.yml')
assert.deepEqual(validate.with, { 'pages-artifact': true })
assert.equal(deploy.needs, 'validate', 'Deploy only after every reusable CI job succeeds')
assert.deepEqual(deploy.permissions, { 'pages': 'write', 'id-token': 'write' })
assert.equal(deploy.environment.name, 'github-pages')
assert.equal(deploy['timeout-minutes'], 10)
assert.equal(deploy.steps.length, 1, 'Deploy must not execute or rebuild downloaded code')
assert.match(deploy.steps[0].uses, /^actions\/deploy-pages@[a-f0-9]{40}$/)
assert(!deploy.steps[0].if && !deploy.steps[0]['continue-on-error'] && !deploy.steps[0].with, 'Use the validated current-run Pages artifact without overrides')
return { dispatch: 'master', deployment: 'github-pages', enabledBy: 'PAGES_DEPLOY_ENABLED' }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
console.log(JSON.stringify(validateCIWorkflow(fs.readFileSync('.github/workflows/nodejs.yml', 'utf8'))))
console.log(JSON.stringify({ ci: validateCIWorkflow(fs.readFileSync('.github/workflows/nodejs.yml', 'utf8')), pages: validatePagesWorkflow(fs.readFileSync('.github/workflows/pages.yml', 'utf8')) }))
+6 -2
View File
@@ -773,11 +773,15 @@
"ENG-02",
"SITE-03"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "Pages artifact 部署配置、旧路径/域名核对、预检和迁移恢复指南",
"acceptance": "部署只取受信任已验证产物;本地实现可验收,远端 source/环境和实际部署状态单独登记",
"evidence": []
"evidence": [
"changes/2026-09-14-CI-02-pages-artifact.md",
"baselines/pages-artifact-validation.json",
"pages-deployment.md"
]
},
{
"id": "CI-03",
+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"]
}
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
// eslint-disable-next-line test/no-import-node-test -- Exercise real filesystem and workflow rejection paths.
import test from 'node:test'
import YAML from 'yaml'
import { validateCIWorkflow, validatePagesWorkflow } from '../refactor/scripts/ci-workflow.mjs'
import { compareCompiled, inside, inventory, validateArtifact } from '../scripts/pages/artifact.ts'
function fixture(t) {
const root = fs.mkdtempSync(path.resolve('refactor/.cache/pages-test-'))
t.after(() => {
assert.equal(path.dirname(root), path.resolve('refactor/.cache'))
assert(path.basename(root).startsWith('pages-test-'))
fs.rmSync(root, { recursive: true, force: true })
})
fs.writeFileSync(path.join(root, 'CNAME'), 'github.artplayer.org\n')
fs.writeFileSync(path.join(root, '.nojekyll'), '')
fs.writeFileSync(path.join(root, 'index.html'), '<!doctype html><title>Player</title>')
return root
}
const contract = { domain: 'github.artplayer.org', requiredPaths: ['index.html', '.nojekyll', 'CNAME'] }
test('Pages preserves hidden files and rejects missing routes, empty pages and changed domains', (t) => {
const root = fixture(t)
const files = validateArtifact(root, contract)
assert.equal(files['.nojekyll'].bytes, 0)
assert.match(files['index.html'].sha256, /^[a-f0-9]{64}$/)
assert.throws(() => validateArtifact(root, { ...contract, requiredPaths: [...contract.requiredPaths, 'mobile.html'] }), /Missing required/)
fs.writeFileSync(path.join(root, 'CNAME'), 'example.com')
assert.throws(() => validateArtifact(root, contract), /domain changed/)
fs.writeFileSync(path.join(root, 'index.html'), '')
assert.throws(() => validateArtifact(root, contract), /Empty Pages/)
})
test('Pages rejects directory escape, symbolic directories and hard-linked files', (t) => {
const root = fixture(t)
for (const file of ['../outside', '/outside', 'nested/../../outside', 'nested\\outside', '.'])
assert.throws(() => inside(root, file))
fs.linkSync(path.join(root, 'index.html'), path.join(root, 'hard.html'))
assert.throws(() => inventory(root), /hard links/)
fs.unlinkSync(path.join(root, 'hard.html'))
const directory = path.join(root, 'assets')
fs.mkdirSync(directory)
fs.symlinkSync(directory, path.join(root, 'redirect'), 'junction')
assert.throws(() => inventory(root), /symbolic links/)
})
test('Pages cannot upload stale compiled output or omit one distribution format', (t) => {
const root = fixture(t)
fs.mkdirSync(path.join(root, 'packages/artplayer/dist'), { recursive: true })
fs.mkdirSync(path.join(root, 'compiled'))
for (const suffix of ['.js', '.legacy.js', '.mjs']) {
fs.writeFileSync(path.join(root, `packages/artplayer/dist/artplayer${suffix}`), suffix)
fs.writeFileSync(path.join(root, `compiled/artplayer${suffix}`), suffix)
}
assert.equal(compareCompiled(root, root, ['artplayer']).length, 3)
fs.writeFileSync(path.join(root, 'compiled/artplayer.js'), 'old')
assert.throws(() => compareCompiled(root, root, ['artplayer']), /Stale compiled/)
fs.unlinkSync(path.join(root, 'compiled/artplayer.js'))
assert.throws(() => compareCompiled(root, root, ['artplayer']), /ENOENT/)
})
test('Pages deployment guard rejects untrusted, bypassed and rebuilding deployments', () => {
const source = fs.readFileSync('.github/workflows/pages.yml', 'utf8')
validatePagesWorkflow(source)
for (const mutate of [
w => w.on.push = {},
w => w.jobs.validate.if = 'true',
w => w.jobs.deploy.needs = [],
w => w.jobs.deploy.permissions.contents = 'write',
w => w.jobs.deploy.steps[0].with = { 'artifact-name': 'arbitrary' },
w => w.jobs.deploy.steps.push({ run: 'yarn build' }),
w => w.concurrency['cancel-in-progress'] = true,
]) {
const workflow = YAML.parse(source)
mutate(workflow)
assert.throws(() => validatePagesWorkflow(YAML.stringify(workflow)))
}
})
test('Pages upload cannot bypass preflight or upload the working directory', () => {
const source = fs.readFileSync('.github/workflows/nodejs.yml', 'utf8')
for (const mutate of [
w => w.jobs.checks.steps.find(s => s.id === 'pages').if = 'false',
w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/upload-pages-artifact@')).with.path = 'docs',
w => w.jobs.checks.steps.find(s => s.id === 'pages').run = 'echo passed',
w => w.jobs.checks.steps.find(s => s.run?.startsWith('yarn test:pages:browser')).if = 'false',
]) {
const workflow = YAML.parse(source)
mutate(workflow)
assert.throws(() => validateCIWorkflow(YAML.stringify(workflow)))
}
})
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable antfu/no-top-level-await -- Sequential standalone browser command; always closes its own server and browsers. */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import process from 'node:process'
import { chromium, firefox, webkit } from 'playwright'
import { serveAsset } from '../scripts/library/assets.ts'
import { digest } from '../scripts/pages/artifact.ts'
const { output } = JSON.parse(fs.readFileSync('refactor/.cache/pages/latest.json', 'utf8'))
assert.equal(path.dirname(path.resolve(output)), path.resolve('refactor/.cache/pages'))
assert(path.basename(output).startsWith('run-'))
const prepared = JSON.parse(fs.readFileSync(path.join(output, 'report.json'), 'utf8'))
assert.equal(prepared.status, 'validated')
const site = path.join(output, 'site')
const reports = []
const failures = []
const server = http.createServer((request, response) => {
const pathname = new URL(request.url, 'http://localhost').pathname
const media = pathname === '/__media.mp4'
serveAsset(media ? path.resolve('test/browser/media') : site, media ? '/pattern.mp4' : pathname, request, response).catch((error) => {
failures.push(String(error))
response.destroy()
})
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const serverUrl = `http://127.0.0.1:${server.address().port}`
try {
for (const [engine, launcher] of Object.entries({ chromium, firefox, webkit })) {
const browser = await launcher.launch()
try {
for (const format of ['demo', 'module']) {
const page = await browser.newPage()
page.setDefaultTimeout(10000)
page.setDefaultNavigationTimeout(10000)
const errors = []
const network = []
page.on('pageerror', error => errors.push(String(error)))
page.on('requestfailed', request => network.push({ url: request.url(), type: request.resourceType(), error: request.failure()?.errorText }))
process.stdout.write(`Checking ${engine}/${format}\n`)
const core = format === 'demo' ? 'uncompiled/artplayer/index.js' : 'compiled/artplayer.mjs'
const chapter = format === 'demo' ? 'uncompiled/artplayer-plugin-chapter/index.js' : 'compiled/artplayer-plugin-chapter.mjs'
for (const file of [core, chapter]) assert.equal(digest(path.join(site, file)).sha256, prepared.files[file].sha256)
const setup = `window.art = new window.Artplayer({ container: '.player', url: '/__media.mp4', muted: true, fullscreenWeb: true, plugins: [window.artplayerPluginChapter({chapters:[{start:0,end:Infinity,title:'Pages staging'}]})] }); document.querySelector('button').onclick = () => art.play();`
const scripts = format === 'demo'
? `<script src="/${core}"></script><script src="/${chapter}"></script><script>${setup}</script>`
: `<script type="module">import Artplayer from '/${core}'; import chapter from '/${chapter}'; window.Artplayer=Artplayer; window.artplayerPluginChapter=chapter; ${setup}</script>`
await page.route('**/__pages_probe.html', route => route.fulfill({ contentType: 'text/html', body: `<!doctype html><button>Play</button><div class="player" style="width:640px;height:360px"></div>${scripts}` }))
try {
await page.goto(`${serverUrl}/__pages_probe.html`)
await page.waitForFunction(() => window.art?.isReady)
await page.locator('button').click()
await page.waitForFunction(() => window.art.playing && window.art.currentTime > 0.2)
assert.equal(await page.locator('.art-chapter').count(), 1)
await page.evaluate(() => {
window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'Updated' }] })
window.art.fullscreenWeb = true
})
await page.waitForFunction(() => window.art.fullscreenWeb)
assert.equal(await page.locator('.art-chapter').getAttribute('data-title'), 'Updated')
const state = await page.evaluate(() => ({ time: window.art.currentTime, width: window.art.video.videoWidth, readyState: window.art.video.readyState, mediaError: window.art.video.error?.code ?? null }))
assert(state.width > 0)
assert.equal(state.mediaError, null)
await page.evaluate(() => window.art.destroy())
assert.equal(await page.locator('.art-video-player').count(), 0)
assert.deepEqual(errors, [])
assert.deepEqual(network.filter(entry => entry.type === 'script'), [])
reports.push({ engine, version: browser.version(), platform: process.platform, format, passed: true, state, network, inputs: { core: prepared.files[core], chapter: prepared.files[chapter] } })
}
catch (error) {
process.stderr.write(`${engine}/${format}: ${String(error)}; ${JSON.stringify(errors)}\n`)
reports.push({ engine, version: browser.version(), format, passed: false, error: String(error), errors, network })
failures.push(`${engine}/${format}: ${String(error)}`)
}
finally {
await page.close()
}
}
}
finally {
await browser.close()
}
}
}
finally {
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
fs.writeFileSync(path.join(output, 'browser.json'), `${JSON.stringify({ prepared: digest(path.join(output, 'report.json')), reports, failures, limitation: 'Controlled probe page and fixed native MP4; scripts served from exact staged directory. Not full live editor, external SDK, physical device or remote Pages verification.' }, null, 2)}\n`)
}
assert.deepEqual(failures, [])
process.stdout.write(`Staged Pages browser checks passed: ${reports.length}. Evidence: ${output}/browser.json\n`)