diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 8ff9a8ce0..d6392d43c 100644
--- a/.github/workflows/nodejs.yml
+++ b/.github/workflows/nodejs.yml
@@ -3,7 +3,7 @@ name: Node CI
on:
pull_request:
push:
- branches: [master, 'codex/**']
+ branches: [ master, 'codex/**' ]
workflow_dispatch:
workflow_call:
inputs:
@@ -27,7 +27,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, windows-latest]
+ os: [ ubuntu-latest, windows-latest ]
defaults:
run:
shell: bash
@@ -42,28 +42,37 @@ jobs:
with:
node-version-file: .node-version
package-manager-cache: false
+ - name: Record source and cache inputs
+ id: context
+ run: node scripts/ci-context.mjs
+ - name: Cache locked Yarn downloads
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 ref verified 2026-09-13; node24
+ with:
+ path: ${{ steps.context.outputs.yarn_cache }}
+ key: yarn-${{ runner.os }}-${{ runner.arch }}-node-${{ steps.context.outputs.node }}-yarn-${{ steps.context.outputs.yarn }}-${{ github.ref }}-${{ hashFiles('yarn.lock') }}
- name: Install the pinned toolchain and dependencies
run: |
- npm install --global --force yarn@1.22.22
- yarn install --frozen-lockfile --non-interactive
+ npm install --global --force yarn@1.22.22 2>&1 | tee refactor/.cache/ci/toolchain-install.log
+ yarn install --frozen-lockfile --non-interactive 2>&1 | tee refactor/.cache/ci/install.log
yarn check:toolchain --strict
- name: Verify source maps and critical lifecycle coverage
- run: yarn test:coverage
+ run: yarn test:coverage 2>&1 | tee refactor/.cache/ci/coverage.log
- name: Upload coverage and source-map evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
+ refactor/.cache/ci/
refactor/.cache/coverage/latest.json
refactor/.cache/coverage/run-*/
include-hidden-files: true
retention-days: 14
if-no-files-found: warn
browser-smoke:
- name: Browser playback smoke
- runs-on: macos-latest
- timeout-minutes: 30
+ name: Browser playback (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 60
env:
CI: true
steps:
@@ -75,28 +84,42 @@ jobs:
with:
node-version-file: .node-version
package-manager-cache: false
+ - name: Record source and cache inputs
+ id: context
+ run: node scripts/ci-context.mjs
+ - name: Cache locked Yarn downloads
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 ref verified 2026-09-13; node24
+ with:
+ path: ${{ steps.context.outputs.yarn_cache }}
+ key: yarn-${{ runner.os }}-${{ runner.arch }}-node-${{ steps.context.outputs.node }}-yarn-${{ steps.context.outputs.yarn }}-${{ github.ref }}-${{ hashFiles('yarn.lock') }}
- name: Install the pinned package manager and dependencies
run: |
- npm install --global --force yarn@1.22.22
- yarn install --frozen-lockfile --non-interactive
+ npm install --global --force yarn@1.22.22 2>&1 | tee refactor/.cache/ci/toolchain-install.log
+ yarn install --frozen-lockfile --non-interactive 2>&1 | tee refactor/.cache/ci/install.log
+ - name: Cache versioned browser downloads
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 ref verified 2026-09-13; node24
+ with:
+ path: ${{ steps.context.outputs.browser_cache }}
+ key: playwright-${{ runner.os }}-${{ runner.arch }}-node-${{ steps.context.outputs.node }}-yarn-${{ steps.context.outputs.yarn }}-${{ github.ref }}-${{ hashFiles('yarn.lock') }}-${{ steps.context.outputs.playwright }}
- name: Install test browsers
- run: yarn test:browser:install
+ run: yarn test:browser:install --with-deps 2>&1 | tee refactor/.cache/ci/browser-install.log
- name: Build and check installed tarballs
run: |
- yarn test:package
+ yarn test:package 2>&1 | tee refactor/.cache/ci/package.log
node --input-type=module -e 'import fs from "node:fs"; const { output } = JSON.parse(fs.readFileSync("refactor/.cache/packages/latest.json")); fs.appendFileSync(process.env.GITHUB_ENV, `ARTPLAYER_BROWSER_ARTIFACTS=${output}/browser-artifacts.json\n`);'
- name: Run all three engines
- run: yarn test:browser
+ run: yarn test:browser 2>&1 | tee refactor/.cache/ci/browser.log
- name: Verify iframe cached history and interrupted navigation
- run: yarn test:iframe-history
+ run: yarn test:iframe-history 2>&1 | tee refactor/.cache/ci/iframe-history.log
- name: Compare installed performance and verify resource cleanup
- run: yarn test:performance
+ run: yarn test:performance 2>&1 | tee refactor/.cache/ci/performance.log
- name: Upload browser evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: browser-${{ github.run_id }}-${{ github.run_attempt }}
+ name: browser-smoke-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
+ refactor/.cache/ci/
refactor/.cache/browser/
refactor/.cache/iframe-history/
refactor/.cache/performance/
@@ -108,10 +131,21 @@ jobs:
include-hidden-files: true
retention-days: 14
if-no-files-found: warn
+ defaults:
+ run:
+ shell: bash
+ strategy:
+ fail-fast: false
+ max-parallel: 3
+ matrix:
+ os:
+ - ubuntu-latest
+ - windows-latest
+ - macos-latest
checks:
- name: Checks and build
- runs-on: ubuntu-latest
- timeout-minutes: 30
+ name: Checks and build (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 45
env:
CI: true
defaults:
@@ -126,14 +160,23 @@ jobs:
with:
node-version-file: .node-version
package-manager-cache: false
+ - name: Record source and cache inputs
+ id: context
+ run: node scripts/ci-context.mjs
+ - name: Cache locked Yarn downloads
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 ref verified 2026-09-13; node24
+ with:
+ path: ${{ steps.context.outputs.yarn_cache }}
+ key: yarn-${{ runner.os }}-${{ runner.arch }}-node-${{ steps.context.outputs.node }}-yarn-${{ steps.context.outputs.yarn }}-${{ github.ref }}-${{ hashFiles('yarn.lock') }}
- name: Install the pinned package manager
- run: npm install --global --force yarn@1.22.22
+ run: npm install --global --force yarn@1.22.22 2>&1 | tee refactor/.cache/ci/toolchain-install.log
- name: Validate workflow syntax
run: |
curl --fail --silent --show-error --location https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz --output "$RUNNER_TEMP/actionlint.tar.gz"
printf '%s %s\n' '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8' "$RUNNER_TEMP/actionlint.tar.gz" | sha256sum --check
tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint
"$RUNNER_TEMP/actionlint" -color
+ if: matrix.os == 'ubuntu-latest'
- name: Frozen install
run: |
mkdir -p refactor/.cache/ci
@@ -155,14 +198,52 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: checks-${{ github.run_id }}-${{ github.run_attempt }}
+ name: checks-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
path: refactor/.cache/ci/
include-hidden-files: true
retention-days: 14
if-no-files-found: warn
- name: Prepare Pages artifact
- if: inputs.pages-artifact && github.ref == 'refs/heads/master'
+ 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
include-hidden-files: true
+ strategy:
+ fail-fast: false
+ max-parallel: 2
+ matrix:
+ os:
+ - ubuntu-latest
+ - windows-latest
+ ci-result:
+ name: CI result
+ if: always()
+ needs:
+ - checks
+ - coverage
+ - browser-smoke
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .node-version
+ package-manager-cache: false
+ - name: Require every matrix job to succeed
+ env:
+ ARTPLAYER_CI_NEEDS: ${{ toJSON(needs) }}
+ run: node scripts/ci-summary.mjs
+ - name: Upload final status
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: ci-result-${{ github.run_id }}-${{ github.run_attempt }}
+ path: refactor/.cache/ci/
+ include-hidden-files: true
+ retention-days: 14
+ if-no-files-found: warn
diff --git a/package.json b/package.json
index f5491815e..b9dc08e73 100644
--- a/package.json
+++ b/package.json
@@ -45,9 +45,9 @@
"check:toolchain": "node scripts/check-toolchain.mjs",
"lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"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: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:baseline": "node --test refactor/scripts/*.test.mjs",
- "ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --report && yarn test:contracts && yarn check:contracts --report && yarn check:plan && yarn lint && yarn check:types && yarn typecheck && yarn test",
+ "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 lint && yarn check:types && yarn typecheck && yarn test",
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
@@ -90,7 +90,9 @@
"test:vtt-thumbnail-types-package": "node refactor/scripts/vtt-thumbnail-package-types.mjs",
"test:multiple-subtitles": "node --test test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js refactor/scripts/multiple-subtitles-contract.test.mjs refactor/scripts/multiple-subtitles-runtime-types.test.mjs refactor/scripts/multiple-subtitles-types.test.mjs",
"test:multiple-subtitles-types-package": "node refactor/scripts/multiple-subtitles-package-types.mjs",
- "test:jassub": "node --test test/jassub.test.js refactor/scripts/jassub-contract.test.mjs"
+ "test:jassub": "node --test test/jassub.test.js refactor/scripts/jassub-contract.test.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"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/refactor/baselines/ci-matrix-validation.json b/refactor/baselines/ci-matrix-validation.json
new file mode 100644
index 000000000..e04aea207
--- /dev/null
+++ b/refactor/baselines/ci-matrix-validation.json
@@ -0,0 +1,112 @@
+{
+ "schemaVersion": 1,
+ "task": "CI-01",
+ "date": "2026-09-13",
+ "baseCommit": "2c73e2b105ee55f95e3ffc906ea4ea1f25221622",
+ "candidate": "working tree in the accompanying CI-01 checkpoint commit",
+ "inputs": [
+ {
+ "file": ".github/workflows/nodejs.yml",
+ "sha256": "823cc76c209f4d54fe66d818a5114333a4412fe710796be7d28bb4cb0bd6f5f6"
+ },
+ {
+ "file": "package.json",
+ "sha256": "c6059a5d3dfddbaf31a389b73db252d612cfb14ea64b19cfe0e9657e0340e0cf"
+ },
+ {
+ "file": "scripts/ci-context.mjs",
+ "sha256": "2a59a334ded9079fbcc7892b8203f59a79e252bb4246b66a61f683e347f1d14d"
+ },
+ {
+ "file": "scripts/ci-summary.mjs",
+ "sha256": "8878541a6ddcbcf23d4e6d86612093f8ce605b1a3c9952cb1d8f463343ed5602"
+ },
+ {
+ "file": "refactor/scripts/ci-workflow.mjs",
+ "sha256": "beb14540abe2784db14462d295883da4f04f9cc14b781851b4e64da5e0693671"
+ },
+ {
+ "file": "refactor/scripts/ci-workflow.test.mjs",
+ "sha256": "9323a85ef5cccb43c512ca86701a3984e3587821faf1ded3a82deeb2878fb8fc"
+ },
+ {
+ "file": "test/ci-summary.test.js",
+ "sha256": "fa28903bb90612c108c5cf89223e7de003137a0f0cc168021c97e46770fcd052"
+ }
+ ],
+ "environment": {
+ "os": "Windows",
+ "node": "24.21.0",
+ "yarn": "1.22.22",
+ "actionlint": "1.7.12"
+ },
+ "focused": {
+ "command": "yarn test:ci",
+ "passed": 37,
+ "failed": 0,
+ "skipped": 0
+ },
+ "ciCheck": {
+ "command": "yarn ci:check",
+ "passed": 2422,
+ "unit": 2031,
+ "engineering": 23,
+ "baseline": 368,
+ "seconds": 244.45
+ },
+ "actionlint": {
+ "exitCode": 0,
+ "logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ "explicitLint": {
+ "exitCode": 0,
+ "logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ "logs": [
+ {
+ "file": "refactor/.cache/ci01-focused.log",
+ "sha256": "e76424c6290d7cddb2a50be95bfb32705c51b07f04bc02728c3ed521acf6e2a2",
+ "retention": "local ignored cache; outcome retained in this report"
+ },
+ {
+ "file": "refactor/.cache/ci01-ci.log",
+ "sha256": "71c6d04073d6af4b6fd225693d785c6e157b6cb8216de1a91e3ffb833d2e8383",
+ "retention": "local ignored cache; outcome retained in this report"
+ },
+ {
+ "file": "refactor/.cache/ci01-actionlint-final.log",
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "retention": "local ignored cache; outcome retained in this report"
+ },
+ {
+ "file": "refactor/.cache/ci01-lint.log",
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "retention": "local ignored cache; outcome retained in this report"
+ },
+ {
+ "file": "refactor/.cache/ci01-package-final.log",
+ "sha256": "b0705f902491cb71954cf8d3f537f9ca05fe027a1cefc2ed93960443443838fc",
+ "retention": "local ignored cache; outcome retained in this report"
+ }
+ ],
+ "cacheAction": {
+ "ref": "refs/tags/v5",
+ "sha": "caa296126883cff596d87d8935842f9db880ef25",
+ "source": "https://api.github.com/repos/actions/cache/git/refs/tags/v5",
+ "runtime": "node24"
+ },
+ "remoteActionsRun": null,
+ "taskComplete": false,
+ "remaining": [
+ "Lowest Node consumer and tooling runtime matrix",
+ "Full ecosystem installed-package/browser scope",
+ "Impact-based selection with equivalent coverage and complete reporting",
+ "CI-04 hosted runner, cache hit/miss, failed PR and required-check evidence"
+ ],
+ "inputByteScope": "Local working-tree bytes; checkout line endings may differ. Only action version comments were added after full ci:check; final actionlint and test:ci reran on the recorded workflow.",
+ "metadata": {
+ "plan": "223 tasks; 121 done, 16 doing, 86 todo",
+ "risks": 200,
+ "testsPassed": 9
+ }
+}
diff --git a/refactor/changes/2026-09-13-CI-01-matrix-summary.md b/refactor/changes/2026-09-13-CI-01-matrix-summary.md
new file mode 100644
index 000000000..2390ffbf2
--- /dev/null
+++ b/refactor/changes/2026-09-13-CI-01-matrix-summary.md
@@ -0,0 +1,61 @@
+# CI-01 系统矩阵、缓存与结果汇总检查点
+
+2026-09-13,基于 2c73e2b105ee55f95e3ffc906ea4ea1f25221622,分支 codex/compatible-modernization。
+本批修改工程流程,不修改播放器或插件的生产源码、公开声明、版本、运行时依赖或构建产物。
+CI-01 保持 doing;没有推送、部署、修改仓库保护设置或发布 npm。
+
+## 旧行为与本批改动
+
+原 checks 仅 Linux,浏览器仅 macOS,coverage 为 Linux/Windows;没有缓存或统一结果作业。
+checks 扩为 Linux/Windows(45 分钟),浏览器扩为 Linux/Windows/macOS(60 分钟),
+coverage 保留双系统(15 分钟)。每个系统运行完整已有命令,矩阵 fail-fast=false;
+浏览器仍是 Chromium/Firefox/WebKit,真实设备和全生态覆盖不能由这个配置推导。
+
+新增固定名称 CI result,依赖 checks、coverage、browser-smoke,并在前序失败后运行。
+只有三个作业组全部 success 才返回 0;失败、取消、跳过、缺失、错误 JSON 或未知额外作业
+均返回 1。GitHub 的 needs 是矩阵作业组的汇总结果,本地不伪造或推断每个远端子作业。
+整个运行被强制取消或 runner 不可用时能否执行最终作业,仍需 CI-04 实际验证。
+
+Yarn 只缓存下载内容,浏览器缓存与 Playwright 精确版本绑定;key 包含 OS、架构、Node、
+Yarn、ref 和锁摘要,不设宽松恢复前缀,不缓存 node_modules 或构建产物。每次冻结安装,
+浏览器即使命中缓存仍 install --with-deps。缓存目录由 Node 在 step 内从 RUNNER_TEMP
+解析,写入 GITHUB_ENV/OUTPUT;初始把 runner.temp 放 job env 的 actionlint 失败已修复。
+
+三个矩阵使用显式 bash,保留 tee 上游失败码;安装和执行日志、源码/工具/锁 metadata、
+各类既有报告 always 上传,名称区分系统、run 和 attempt,保留 14 天。Pages 产物只有
+受信任 master 的 Linux checks 分支上传,部署仍由原独立 workflow 的准入门槛控制。
+
+## 模块与维护入口
+
+- scripts/ci-context.mjs:只依赖 Node 内置模块,读取真实 checkout 和固定版本/锁,写运行 metadata 与缓存路径。
+- scripts/ci-summary.mjs:独立评估结构化 needs,生成 summary.json/Markdown、Job Summary 和 CLI 退出码。
+- refactor/scripts/ci-workflow.mjs:用现有 yaml 2.8.2 解析实际工作流,保护矩阵、不可变 Actions、安装顺序、权限、报告和最终依赖。
+- test/ci-summary.test.js:运行真实 CLI 的成功/失败/无效 JSON、结果缺失与环境文件;纳入 test:node。
+- refactor/scripts/ci-workflow.test.mjs:真实 YAML 正例和 20 个破坏条件的反例;纳入 test:baseline。
+- 现有 impact-model/impact.test 继续保护六项必需命令及全生态影响传播。
+
+新增 yarn check:ci(只读工作流校验,纳入 ci:check)和 yarn test:ci(以上三组 37 项)。
+没有修改依赖或 yarn.lock。新增 Actions/cache 采用官方 v5 ref 当次解析的完整 SHA
+caa296126883cff596d87d8935842f9db880ef25,其 action.yml 使用 node24;不使用浮动 tag 执行。
+升级缓存 Action 时重新核对官方 ref/action.yml 和 runner 要求,不把 Node Action 运行时
+等同于播放器消费者的最低 Node。
+
+## 验证与范围
+
+本地 Windows,Node 24.21.0 / Yarn 1.22.22:定向 37 项通过;完整 ci:check 2422 项通过
+(2031 单元、23 工程、368 基线)。actionlint 1.7.12 与新增文件的只读 ESLint 通过。
+原有 Yarn deprecation/生成声明提示仍存在,不声称整个 CI 无警告。
+源码和日志指纹、计数与运行时长见 [验证记录](../baselines/ci-matrix-validation.json)。
+
+没有新跑完整浏览器播放、ci:build 或 GitHub hosted jobs:这次没有修改生产源码和产物,
+主要验证编排与错误传播。跨平台构建/浏览器、缓存命中和失效的实际结果仍是缺口,
+配置成功不计入这些远端验收。现有 test:package 只对 core/chapter 实际安装,不能称全包消费通过。
+
+## 后续与回退
+
+CI-01 继续最低 Node/TS 消费者组合、全包 installed/browser 覆盖和有完整证据的影响范围调度。
+当前保留全量运行,影响报告用于解释范围,不输出跳过必需检查的开关。CI-04 负责真实 PR
+成功/失败/取消、各系统、缓存冷/热运行及稳定检查绑定的远端验收;这些均未完成。
+公开 JS/TS、事件/DOM/CSS、分发入口无变化,不需要用户迁移。
+回退本检查点可恢复旧编排和脚本入口,不需要重新生成库产物;远端设置未改,无远端回退动作。
+提交主题:ci: [CI-01] add OS matrices, scoped caches and required result gate。
diff --git a/refactor/ci-setup.md b/refactor/ci-setup.md
index 419ca7f8d..10bb0ef1d 100644
--- a/refactor/ci-setup.md
+++ b/refactor/ci-setup.md
@@ -18,6 +18,8 @@
| `yarn test:contracts` | 重跑已登记断言的Node文件并采集精确事件/候选指纹,当前44项包含10个索引断言 |
| `yarn check:contracts --report` | 校验12类契约/22包归属、版本及报告对应;--write更新静态表,详见 [维护说明](contract-coverage.md) |
| `yarn ci:check` | 严格 Node/Yarn/锁检查、计划、只读 lint、类型、Node 和基线测试;允许写忽略缓存,不修改源码 |
+| `yarn check:ci` | 只读校验实际工作流的完整系统矩阵、安装、缓存、报告和最终检查;已接入 ci:check |
+| `yarn test:ci` | CI 汇总真实退出码、工作流破坏反例与全包影响分析,共 37 项 |
| `yarn ci:build` | 21 库包、i18n、编辑器声明和文档站构建,以及构建后包导入 smoke;会生成 dist 和 docs 内容 |
| `yarn check:impact --report` | 读取实际依赖/验证关系和Git变更,核对workflow必需命令,写CI影响报告;已接入ci:check,见[影响映射](impact-analysis.md) |
| `yarn build:all` | 保留旧入口,执行 ci:build 后只读 lint |
@@ -26,11 +28,55 @@ scripts/build-docs.js 保留原 npm run build 子命令兼容入口,现在传
## PR 和主线
-.github/workflows/nodejs.yml 在 PR、master/codex/** push 和手动运行时触发,也供 Pages 复用。Checks and build 作业只授予 contents: read,checkout 不保留 Git 凭据,没有提交、推送、npm 发布或部署步骤。PR/推送取消过期运行,手动部署不被中途取消;初始 Ubuntu 作业限时 30 分钟。
+.github/workflows/nodejs.yml 在 PR、master/codex/** push 和手动运行时触发,也供 Pages 复用。检查作业只授予 contents: read,checkout 不保留 Git 凭据,没有提交、推送、npm 发布或部署步骤。PR/推送取消过期运行;Pages 部署继续使用独立队列。
Actions 固定完整 SHA,Node 来自 .node-version,Yarn 固定 1.22.22;安装使用 frozen-lockfile。actionlint 1.7.12 从官方固定归档取得,先核对提交在 workflow 中的 SHA-256,再执行。没有执行 curl 管道脚本。日志在失败时也尽量上传,包含源码 SHA、工具版本、锁摘要和生成差异概览,保留 14 天。
-不缓存 node_modules。ENG-04 已接入类型消费者;ENG-05 新增 macOS 的 `Browser playback smoke` 作业,运行三个浏览器并 always 上传 HTML/JSON、失败截图和 trace。本地 Windows 三浏览器已验证,远端 macOS 作业尚未运行。完整生态矩阵、下载缓存、覆盖率和候选 tarball 汇总由 CI-01 等后续任务扩展。稳定 required 汇总名称由 CI-01/CI-04 核实后设置,不把本地 YAML 当作分支保护已生效。
+## CI-01 当前矩阵与结果汇总
+
+| 作业 | 系统 | 内容 | 超时 |
+| --- | --- | --- | --- |
+| checks | Linux、Windows | ci:check、ci:build;Linux 额外执行 actionlint | 45 分钟 |
+| coverage | Linux、Windows | 既有源码映射与生命周期覆盖检查 | 15 分钟 |
+| browser-smoke | Linux、Windows、macOS | 实际 core/chapter 安装产物、Chromium/Firefox/WebKit、iframe history、性能 | 60 分钟 |
+| CI result | Linux | 汇总以上三个作业组,所有结果必须为 success | 5 分钟 |
+
+Node 均使用 .node-version 的 24.21.0。TS 5.9.3、4.3.5 和迁移运行时兼容 5.1.6
+继续由既有类型脚本按各自适用范围运行,不代表最低 Node 或所有包/TS 组合已验收。
+矩阵不设置 fail-fast,单个系统失败后仍尽量收集其他系统证据;显式 bash 保留 tee
+上游命令的失败退出码。影响报告继续扩大核心/共享变更到全生态,当前不缩减必需作业。
+
+稳定名称为 **CI result**。它以 always() 依赖 checks、coverage、browser-smoke,结构化
+读取 needs;失败、取消、跳过、缺失、错误 JSON 或未知额外作业都失败。增加独立 job 时
+同时修改 ci-summary.mjs 的 requiredJobs、workflow needs、矩阵策略和测试,防止遗漏汇总。
+全局取消/runner 故障下的真实调度仍待 CI-04;本地退出码测试不证明远端作业一定被调度。
+required checks 的实际绑定尚未设置,不能宣称分支保护已经生效。
+
+## 下载缓存、日志与维护
+
+scripts/ci-context.mjs 从真实 checkout、package.json、.node-version 和 yarn.lock 读取
+来源与版本,将绝对缓存目录写入 GITHUB_ENV/OUTPUT,运行信息写入 refactor/.cache/ci/context.json。
+Yarn 缓存与浏览器缓存均按 OS/架构/Node/Yarn/ref/锁隔离,浏览器另绑定 Playwright 版本;
+只缓存下载内容,无宽松 restore-keys、跨 OS 恢复或 node_modules/产物缓存。每次 frozen
+install;浏览器每次 install --with-deps,缓存命中不能替代 Linux 系统依赖安装。
+相关行为依据 [Playwright CI 文档](https://playwright.dev/docs/ci)。
+
+新增 actions/cache 固定 caa296126883cff596d87d8935842f9db880ef25,来自当次核实的
+[官方 v5 ref](https://api.github.com/repos/actions/cache/git/refs/tags/v5),
+[该提交 action.yml](https://github.com/actions/cache/blob/caa296126883cff596d87d8935842f9db880ef25/action.yml)
+使用 node24;升级时重新核对 tag/SHA、runner 和缓存格式,不使用浮动标签执行。
+本批没有新增 npm 依赖或修改 yarn.lock。
+
+安装/构建/测试日志和已有 HTML/JSON、截图、trace、tarball 由 always 上传步骤留存;
+名称包含系统、run_id 和 run_attempt,保留 14 天。最终脚本独立写 summary.json、
+summary.md 和 GitHub Job Summary,不依赖先前下载的 artifact 来判断作业是否成功。
+下载对应作业 artifact,先核对 context.json 的 source/workflowSource、工具版本和锁摘要,
+再查看失败阶段的日志和浏览器 trace。缓存异常时可删除对应远端 key 后重跑;该动作
+必须针对实际故障,不以清缓存代替修复锁或构建问题。
+
+本地验证与指纹见 [CI-01 记录](changes/2026-09-13-CI-01-matrix-summary.md)。
+最低 Node 消费/工具环境、全生态安装矩阵和有证据的影响调度仍待 CI-01;CI-04 负责
+各系统远端运行、冷热缓存、失败/取消演练及 required check 设置。没有新增远端通过证据。
## Pages 隔离与启用条件
diff --git a/refactor/github-ci-cd.md b/refactor/github-ci-cd.md
index 6909e0487..eb59c1ae6 100644
--- a/refactor/github-ci-cd.md
+++ b/refactor/github-ci-cd.md
@@ -1,6 +1,6 @@
# GitHub CI/CD 优化与验收
-2026-09-10 用户明确要求在本次重构中优化和增强 GitHub CI/CD。范围包含仓库检查、测试矩阵、构建产物、文档站部署和 npm 发布准备,并持续维护脚本文档及故障处理指南。ENG-02 已实现初步检查与部署隔离,操作见 [ci-setup.md](ci-setup.md);完整矩阵及远端验收仍待后续任务。
+2026-09-10 用户明确要求在本次重构中优化和增强 GitHub CI/CD。范围包含仓库检查、测试矩阵、构建产物、文档站部署和 npm 发布准备,并持续维护脚本文档及故障处理指南。ENG-02 已实现初步检查与部署隔离,CI-01 已扩展系统矩阵、下载缓存、失败报告与稳定 CI result,操作见 [ci-setup.md](ci-setup.md)。CI-01 仍在进行:最低 Node 消费环境、全生态安装组合和影响调度尚未完成;CI-04 远端验收仍缺证据。
## ENG-02 前源码基线
diff --git a/refactor/plan.md b/refactor/plan.md
index df15f54fe..7616c67f7 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 223 项,范围 22 个包及工作区/示例。
-状态:todo 87 / doing 15 / blocked 0 / done 121 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 86 / doing 16 / blocked 0 / done 121 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -93,7 +93,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
-| CI-01 | workspace
增强兼容矩阵、并发缓存与 CI 报告 | DOC-10, ENG-08, ENG-09, ENG-10 | OS/Node/TS/浏览器与影响范围矩阵、缓存、超时、汇总检查和 artifact 报告 | 固定安装、失败/取消不误报、核心影响全生态;检查只读,失败证据可追溯 | H | todo |
+| CI-01 | workspace
增强兼容矩阵、并发缓存与 CI 报告 | DOC-10, ENG-08, ENG-09, ENG-10 | OS/Node/TS/浏览器与影响范围矩阵、缓存、超时、汇总检查和 artifact 报告 | 固定安装、失败/取消不误报、核心影响全生态;检查只读,失败证据可追溯 | H | doing |
| CI-02 | workspace
分离并改进 GitHub Pages 部署 | DOC-10, ENG-02, SITE-03 | Pages artifact 部署配置、旧路径/域名核对、预检和迁移恢复指南 | 部署只取受信任已验证产物;本地实现可验收,远端 source/环境和实际部署状态单独登记 | H | todo |
| CI-03 | workspace
建立 npm 分包候选与发布工作流 | DOC-10, CI-01, REL-08, REL-04 | 候选准备、精确 artifact 发布配置、OIDC 评估、版本/tag/registry 预检和部分失败恢复 | 不自动发布;明确逐包信任前置和 dry run 限制,不能重建未验证内容或重发冲突版本;按版本清单校验各包下一 major 和预发布/正式 tag,保留旧核心支持范围 | H | todo |
| CI-04 | workspace
验收 GitHub 流水线与远端发布准入 | CI-01, CI-02, CI-03, SITE-06 | 静态/干净环境检查、真实 PR 正反例、候选 dry run、required checks/Pages/npm 必需配置状态及运维指南 | 必要 Actions 证据和远端配置核对齐全;缺失保持未完成,真实 publish/deploy 仍在授权发布步骤执行 | H | todo |
@@ -454,6 +454,7 @@
- ENG-09: [记录](changes/2026-09-12-ENG-09-integration.md) [记录](baselines/engineering-integration.json)
- 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)
- 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)
diff --git a/refactor/progress.md b/refactor/progress.md
index 3e2f5a4a6..2c4e15829 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,18 @@
# 进度与证据
+## CI-01 系统矩阵、缓存与结果汇总检查点(doing)
+
+checks 扩为 Linux/Windows,浏览器扩为 Linux/Windows/macOS;coverage 保留双系统。
+固定来源下载缓存、每次冻结安装/浏览器系统依赖、失败日志和稳定 CI result 已实现。
+最终作业只接受所有必需组 success;真实 CLI 和工作流反例共 37 项通过。
+完整本地 ci:check 2422 项通过(2031 单元、23 工程、368 基线),actionlint 与定向 lint 通过。
+见[变更](changes/2026-09-13-CI-01-matrix-summary.md)和[证据](baselines/ci-matrix-validation.json)。
+没有新生产源码/类型/依赖/版本/产物变更,没有新浏览器播放或远端 Actions 运行。
+CI-01 保持 doing:最低 Node 消费环境、全包安装矩阵与影响调度仍待完成;CI-04 验证远端。
+223 项:121 done、16 doing、86 todo;本次未新增或关闭风险。独立本地检查点,不推送、不发布。
+下一步继续消费者运行时矩阵和插件缺口;此前类型决策与第三方来源事项保持未完成。
+
+
## PKG-JASSUB-01 发布与来源检查点(doing)
冻结两个真实 npm 版本、12 成员、9 份 Git 文本及本地 worker/WASM/font/ASS/MP4 指纹。
diff --git a/refactor/scripts/ci-workflow.mjs b/refactor/scripts/ci-workflow.mjs
new file mode 100644
index 000000000..af230c2a6
--- /dev/null
+++ b/refactor/scripts/ci-workflow.mjs
@@ -0,0 +1,77 @@
+/* eslint-disable no-template-curly-in-string -- GitHub workflow expressions are literal contract data. */
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import YAML from 'yaml'
+import { requiredJobs } from '../../scripts/ci-summary.mjs'
+
+const systems = {
+ 'checks': ['ubuntu-latest', 'windows-latest'],
+ 'coverage': ['ubuntu-latest', 'windows-latest'],
+ 'browser-smoke': ['ubuntu-latest', 'windows-latest', 'macos-latest'],
+}
+
+export function validateCIWorkflow(source) {
+ const workflow = YAML.parse(source, { uniqueKeys: true })
+ assert.deepEqual(workflow.permissions, { contents: 'read' }, 'CI permissions must remain read-only')
+ assert.deepEqual(Object.keys(workflow.jobs).sort(), [...requiredJobs, 'ci-result'].sort(), 'Every job must participate in the summary policy')
+ const summary = workflow.jobs['ci-result']
+ assert.equal(summary.name, 'CI result', 'Keep the required-check name stable')
+ assert.equal(summary.if, 'always()', 'Summary must run after failed or cancelled dependencies')
+ assert.deepEqual([...summary.needs].sort(), [...requiredJobs].sort(), 'Summary must depend on every required job')
+ const gate = summary.steps.find(step => step.run === 'node scripts/ci-summary.mjs')
+ assert(gate && !Object.hasOwn(gate, 'if'), 'Final gate must execute unconditionally')
+ assert.deepEqual(gate.env, { ARTPLAYER_CI_NEEDS: '${{ toJSON(needs) }}' }, 'Pass results as structured data, not shell interpolation')
+ for (const [id, job] of Object.entries(workflow.jobs)) {
+ assert(!job['continue-on-error'], 'Jobs cannot allow failure')
+ assert(job['timeout-minutes'] > 0 && job['timeout-minutes'] <= 60, 'Every job needs a bounded timeout')
+ if (job.permissions)
+ assert.deepEqual(job.permissions, { contents: 'read' }, 'Job permissions must remain read-only')
+ const checkout = job.steps.find(step => step.uses?.startsWith('actions/checkout@'))
+ assert(checkout && !Object.hasOwn(checkout, 'if') && checkout.with?.['fetch-depth'] === 0 && checkout.with['persist-credentials'] === false, 'Every job needs unconditional full-history checkout without saved credentials')
+ const setup = job.steps.find(step => step.uses?.startsWith('actions/setup-node@'))
+ assert(setup && !Object.hasOwn(setup, 'if') && setup.with?.['node-version-file'] === '.node-version' && setup.with['package-manager-cache'] === false && !setup.with.cache, 'Use the canonical Node and explicit download caches')
+ for (const step of job.steps) {
+ assert(!step['continue-on-error'], 'Steps cannot hide failures')
+ if (step.uses)
+ assert(/^[\w.-]+\/[\w./-]+@[a-f0-9]{40}$/.test(step.uses), 'Actions require immutable commit pins')
+ }
+ const upload = job.steps.find(step => step.uses?.startsWith('actions/upload-artifact@'))
+ assert(upload?.if === 'always()' && upload.with?.['include-hidden-files'] === true, 'Always preserve available hidden-cache reports')
+ assert(upload.with.path.includes('refactor/.cache/ci/'), 'Every job must preserve source and execution logs')
+ for (const part of ['github.run_id', 'github.run_attempt']) assert(upload.with.name.includes(part), 'Artifacts must be distinct across runs and attempts')
+ if (id === 'ci-result')
+ continue
+ assert(!Object.hasOwn(job, 'if'), 'Required matrix jobs must not be skipped')
+ assert.equal(job['runs-on'], '${{ matrix.os }}')
+ assert.deepEqual(job.strategy.matrix, { os: systems[id] }, 'Do not silently narrow the OS matrix or exclude combinations')
+ assert.equal(job.strategy['fail-fast'], false, 'Do not cancel other matrix evidence after a failure')
+ assert.equal(job.defaults?.run?.shell, 'bash', 'Tee pipelines require the explicit Actions bash pipefail shell')
+ assert(upload.with.name.includes('matrix.os'), 'Matrix artifacts must have distinct names')
+ const contextIndex = job.steps.findIndex(step => step.id === 'context' && step.run === 'node scripts/ci-context.mjs' && !Object.hasOwn(step, 'if'))
+ assert(contextIndex >= 0, 'Cache inputs must come from the checked source')
+ const caches = job.steps.filter(step => step.uses?.startsWith('actions/cache@'))
+ assert.equal(caches.length, id === 'browser-smoke' ? 2 : 1, 'Only explicit Yarn and browser download caches are expected')
+ for (const cache of caches) {
+ assert(job.steps.indexOf(cache) > contextIndex, 'Record context before restoring caches')
+ assert(!cache.with['restore-keys'] && !cache.with.enableCrossOsArchive, 'Do not restore loosely matched or cross-OS downloads')
+ assert(['${{ steps.context.outputs.yarn_cache }}', '${{ steps.context.outputs.browser_cache }}'].includes(cache.with.path), 'Do not cache node_modules or generated artifacts')
+ for (const part of ['runner.os', 'runner.arch', 'steps.context.outputs.node', 'steps.context.outputs.yarn', 'github.ref', 'hashFiles(\'yarn.lock\')'])
+ assert(cache.with.key.includes(part), `Cache key must isolate ${part}`)
+ if (cache.with.path.includes('browser_cache'))
+ assert(cache.with.key.includes('steps.context.outputs.playwright'), 'Browser cache must follow the exact Playwright version')
+ }
+ const installIndex = job.steps.findIndex(step => step.run?.split('\n').some(line => /^yarn install --frozen-lockfile --non-interactive(?: 2>&1 \| tee refactor\/\.cache\/ci\/install\.log)?$/.test(line)) && !Object.hasOwn(step, 'if'))
+ assert(installIndex > contextIndex && caches.filter(cache => cache.with.path.includes('yarn_cache')).every(cache => job.steps.indexOf(cache) < installIndex), 'Always perform a frozen install after Yarn cache restore')
+ }
+ const browser = workflow.jobs['browser-smoke']
+ assert(browser.steps.some(step => step.run?.startsWith('yarn test:browser:install --with-deps') && !Object.hasOwn(step, 'if')), 'Browser dependencies must install even on cache hits')
+ 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')
+ return { jobs: requiredJobs, systems, summary: summary.name }
+}
+
+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'))))
diff --git a/refactor/scripts/ci-workflow.test.mjs b/refactor/scripts/ci-workflow.test.mjs
new file mode 100644
index 000000000..71cf1f6e1
--- /dev/null
+++ b/refactor/scripts/ci-workflow.test.mjs
@@ -0,0 +1,41 @@
+/* eslint-disable no-template-curly-in-string -- GitHub workflow expressions are literal contract data. */
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+// eslint-disable-next-line test/no-import-node-test -- Workflow contract regression runner.
+import test from 'node:test'
+import YAML from 'yaml'
+import { validateCIWorkflow } from './ci-workflow.mjs'
+
+const source = fs.readFileSync('.github/workflows/nodejs.yml', 'utf8')
+test('CI workflow preserves complete matrix jobs, pinned download caches and an unconditional summary', () => {
+ assert.equal(validateCIWorkflow(source).summary, 'CI result')
+})
+
+for (const [name, mutate] of [
+ ['missing required dependency', w => w.jobs['ci-result'].needs.pop()],
+ ['success-only summary', w => w.jobs['ci-result'].if = 'success()'],
+ ['conditional result gate', w => w.jobs['ci-result'].steps.find(s => s.run).if = 'success()'],
+ ['softened gate', w => w.jobs['ci-result'].steps.find(s => s.run)['continue-on-error'] = true],
+ ['untracked extra job', w => w.jobs.extra = w.jobs.coverage],
+ ['matrix exclusion', w => w.jobs.checks.strategy.matrix.exclude = [{ os: 'windows-latest' }]],
+ ['removed Windows build', w => w.jobs.checks.strategy.matrix.os.pop()],
+ ['fail-fast cancellation', w => w.jobs.coverage.strategy['fail-fast'] = true],
+ ['artifact name collision', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.name = 'checks'],
+ ['write permissions', w => w.permissions.contents = 'write'],
+ ['mutable action ref', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/cache@')).uses = 'actions/cache@v5'],
+ ['dependency installation cache', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/cache@')).with.path = 'node_modules'],
+ ['cross-branch cache fallback', w => w.jobs.coverage.steps.find(s => s.uses?.startsWith('actions/cache@')).with['restore-keys'] = 'yarn-'],
+ ['cache without runtime', w => w.jobs.coverage.steps.find(s => s.uses?.startsWith('actions/cache@')).with.key = '${{ runner.os }}-${{ github.ref }}'],
+ ['skipped dependencies on browser cache hit', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:browser:install')).if = 'steps.cache.outputs.cache-hit != \'true\''],
+ ['Windows pipeline without pipefail', w => w.jobs.checks.defaults.run.shell = 'sh'],
+ ['conditional frozen install', w => w.jobs.checks.steps.find(s => s.run?.includes('yarn install --frozen-lockfile')).if = 'false'],
+ ['commented frozen install', w => w.jobs.checks.steps.find(s => s.run?.includes('yarn install --frozen-lockfile')).run = '# yarn install --frozen-lockfile --non-interactive'],
+ ['conditional Node setup', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/setup-node@')).if = 'false'],
+ ['Pages uploaded by both matrix legs', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/upload-pages-artifact@')).if = 'inputs.pages-artifact && github.ref == \'refs/heads/master\''],
+]) {
+ test(`CI rejects ${name}`, () => {
+ const workflow = YAML.parse(source)
+ mutate(workflow)
+ assert.throws(() => validateCIWorkflow(YAML.stringify(workflow)), { name: 'AssertionError' })
+ })
+}
diff --git a/refactor/tasks.json b/refactor/tasks.json
index d72da9cd0..3fe62788e 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -749,11 +749,15 @@
"ENG-09",
"ENG-10"
],
- "status": "todo",
+ "status": "doing",
"risk": "H",
"deliverable": "OS/Node/TS/浏览器与影响范围矩阵、缓存、超时、汇总检查和 artifact 报告",
"acceptance": "固定安装、失败/取消不误报、核心影响全生态;检查只读,失败证据可追溯",
- "evidence": []
+ "evidence": [
+ "ci-setup.md",
+ "changes/2026-09-13-CI-01-matrix-summary.md",
+ "baselines/ci-matrix-validation.json"
+ ]
},
{
"id": "CI-02",
diff --git a/scripts/ci-context.mjs b/scripts/ci-context.mjs
new file mode 100644
index 000000000..41536289c
--- /dev/null
+++ b/scripts/ci-context.mjs
@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+
+export function ciContext(root, env = process.env) {
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
+ const node = fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim()
+ const yarn = manifest.packageManager?.replace(/^yarn@/, '')
+ const playwright = manifest.devDependencies['@playwright/test']
+ for (const version of [node, yarn, playwright]) assert(/^\d+\.\d+\.\d+$/.test(version), 'CI cache inputs must use exact versions')
+ assert.equal(manifest.packageManager, `yarn@${yarn}`)
+ const source = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim()
+ const downloads = path.resolve(env.RUNNER_TEMP || path.join(root, 'refactor/.cache/downloads'))
+ return {
+ schemaVersion: 1,
+ source,
+ workflowSource: env.GITHUB_SHA || null,
+ ref: env.GITHUB_REF || null,
+ event: env.GITHUB_EVENT_NAME || 'local',
+ run: env.GITHUB_RUN_ID || null,
+ attempt: env.GITHUB_RUN_ATTEMPT || null,
+ runUrl: env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : null,
+ os: process.platform,
+ arch: process.arch,
+ node,
+ actualNode: process.versions.node,
+ yarn,
+ playwright,
+ yarnCache: path.join(downloads, 'artplayer-yarn-cache'),
+ browserCache: path.join(downloads, 'artplayer-playwright'),
+ lockSha256: createHash('sha256').update(fs.readFileSync(path.join(root, 'yarn.lock'))).digest('hex'),
+ }
+}
+
+export function writeContext(root, env = process.env) {
+ const context = ciContext(root, env)
+ const directory = path.join(root, 'refactor/.cache/ci')
+ fs.mkdirSync(directory, { recursive: true })
+ fs.writeFileSync(path.join(directory, 'context.json'), `${JSON.stringify(context, null, 2)}\n`)
+ for (const value of [context.yarnCache, context.browserCache]) assert(!/[\r\n]/.test(value), 'CI paths must fit one environment-file line')
+ if (env.GITHUB_OUTPUT)
+ fs.appendFileSync(env.GITHUB_OUTPUT, `node=${context.node}\nyarn=${context.yarn}\nplaywright=${context.playwright}\nyarn_cache=${context.yarnCache}\nbrowser_cache=${context.browserCache}\n`)
+ if (env.GITHUB_ENV)
+ fs.appendFileSync(env.GITHUB_ENV, `YARN_CACHE_FOLDER=${context.yarnCache}\nPLAYWRIGHT_BROWSERS_PATH=${context.browserCache}\n`)
+ return context
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
+ console.log(JSON.stringify(writeContext(process.cwd())))
diff --git a/scripts/ci-summary.mjs b/scripts/ci-summary.mjs
new file mode 100644
index 000000000..ef082eaf5
--- /dev/null
+++ b/scripts/ci-summary.mjs
@@ -0,0 +1,65 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import { ciContext } from './ci-context.mjs'
+
+export const requiredJobs = ['checks', 'coverage', 'browser-smoke']
+
+export function evaluateJobs(needs) {
+ const object = needs !== null && typeof needs === 'object' && !Array.isArray(needs)
+ const errors = []
+ if (!object)
+ errors.push('Job results must be an object')
+ else if (Object.keys(needs).some(name => !requiredJobs.includes(name)))
+ errors.push('Unexpected job entries; update the explicit required-job policy')
+ const rows = requiredJobs.map((job) => {
+ const value = object && Object.hasOwn(needs, job) ? needs[job]?.result : undefined
+ const result = ['success', 'failure', 'cancelled', 'skipped'].includes(value) ? value : 'missing-or-invalid'
+ return { job, result, passed: result === 'success' }
+ })
+ return { passed: errors.length === 0 && rows.every(row => row.passed), rows, errors }
+}
+
+export function renderSummary(report) {
+ return [
+ '## ArtPlayer CI',
+ '',
+ `Result: **${report.passed ? 'passed' : 'failed'}**`,
+ '',
+ '| Required job | Result |',
+ '| --- | --- |',
+ ...report.rows.map(row => `| ${row.job} | ${row.result} |`),
+ '',
+ 'Success requires every configured matrix job to succeed. Skipped, cancelled and missing results do not pass.',
+ '',
+ ].join('\n')
+}
+
+export function summarize(root, env = process.env) {
+ let needs
+ let invalidJSON = false
+ try {
+ needs = JSON.parse(env.ARTPLAYER_CI_NEEDS || '')
+ }
+ catch {
+ invalidJSON = true
+ }
+ const report = { schemaVersion: 1, context: ciContext(root, env), ...evaluateJobs(needs) }
+ if (invalidJSON)
+ report.errors.push('Job results were not valid JSON')
+ const directory = path.join(root, 'refactor/.cache/ci')
+ fs.mkdirSync(directory, { recursive: true })
+ fs.writeFileSync(path.join(directory, 'summary.json'), `${JSON.stringify(report, null, 2)}\n`)
+ const markdown = renderSummary(report)
+ fs.writeFileSync(path.join(directory, 'summary.md'), markdown)
+ if (env.GITHUB_STEP_SUMMARY)
+ fs.appendFileSync(env.GITHUB_STEP_SUMMARY, markdown)
+ return report
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const report = summarize(process.cwd())
+ console.log(renderSummary(report))
+ process.exitCode = report.passed ? 0 : 1
+}
diff --git a/test/README.md b/test/README.md
index 1e9f37cf1..67f1e3af6 100644
--- a/test/README.md
+++ b/test/README.md
@@ -21,6 +21,7 @@ rather than assuming a fixed number of microtasks. The published defect observat
| ------------------- | -------------------------------------------------------------------------------------------------------- |
| `yarn test:unit` | Playback and DASH regressions, public contracts against released/current code, and JS/TS fixture loading |
| `yarn test:node` | Unit contracts plus toolchain and documentation build failure propagation |
+| `yarn test:ci` | Actual CI summary exit codes, workflow regression guards and repository impact analysis |
| `yarn test` | Node checks and the committed baseline/tooling tests in refactor/scripts |
| `yarn ci:check` | Toolchain, plan, read-only lint, types, then yarn test |
| `yarn test:imports` | Existing distribution import smoke examples; run after building |
@@ -42,6 +43,13 @@ Set `ARTPLAYER_TEST_CORE` to a built `.js`, `.legacy.js` or `.mjs` core file to
When a production module moves, update its loader mapping and its maintenance documentation, preserving the behavioral assertions. New contracts belong in contracts/; test-specific controlled state belongs in helpers/. Do not modify frozen refactor/fixtures or baseline captures just to pass changed behavior. Record historical defects and candidate fixes separately.
+`ci-summary.test.js` exercises the real CLI with success, cancelled and malformed provider data,
+plus missing groups and metadata/environment files. It is included in `test:node`.
+`refactor/scripts/ci-workflow.test.mjs` parses the actual YAML and rejects broken matrix,
+cache, install, report and summary requirements; it is included in `test:baseline`.
+These local tests do not replace hosted runner or branch-protection evidence.
+See [CI operations](../refactor/ci-setup.md) before changing a required job or its cache.
+
# Real browser tests
`test/utils.test.js` compares the published and current utilities, including exact formatting,
diff --git a/test/ci-summary.test.js b/test/ci-summary.test.js
new file mode 100644
index 000000000..f6c8bf63a
--- /dev/null
+++ b/test/ci-summary.test.js
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+// eslint-disable-next-line test/no-import-node-test -- Exercise actual CI exit codes and report files.
+import test from 'node:test'
+import { ciContext, writeContext } from '../scripts/ci-context.mjs'
+import { evaluateJobs, renderSummary, requiredJobs } from '../scripts/ci-summary.mjs'
+
+const success = () => Object.fromEntries(requiredJobs.map(job => [job, { result: 'success' }]))
+test('CI summary requires every configured group and does not mutate provider results', () => {
+ const needs = success()
+ const copy = structuredClone(needs)
+ const report = evaluateJobs(needs)
+ assert.equal(report.passed, true)
+ assert.deepEqual(needs, copy)
+ assert.deepEqual(report.rows.map(row => row.job), requiredJobs)
+})
+
+for (const result of ['failure', 'cancelled', 'skipped', 'unknown', undefined]) {
+ test(`CI summary fails when any required matrix group returns ${result}`, () => {
+ for (const job of requiredJobs) {
+ const needs = success()
+ needs[job].result = result
+ assert.equal(evaluateJobs(needs).passed, false)
+ delete needs[job]
+ assert.equal(evaluateJobs(needs).passed, false)
+ }
+ })
+}
+
+test('CI rejects malformed or additional job results and does not render arbitrary status text', () => {
+ for (const input of [null, [], 'success', {}, { ...success(), extra: { result: 'success' } }])
+ assert.equal(evaluateJobs(input).passed, false)
+ const needs = success()
+ needs.checks.result = '
'
+ assert(!renderSummary(evaluateJobs(needs)).includes('
{
+ assert(path.dirname(root) === path.resolve('refactor/.cache') && path.basename(root).startsWith('ci-summary-test-'))
+ fs.rmSync(root, { recursive: true, force: true })
+ })
+ return root
+}
+
+test('CI context uses exact source/tool/lock inputs and writes absolute cache paths via environment files', (t) => {
+ const root = fixture(t)
+ const output = path.join(root, 'outputs')
+ const envFile = path.join(root, 'environment')
+ const env = { GITHUB_OUTPUT: output, GITHUB_ENV: envFile, RUNNER_TEMP: path.join(root, 'runner temp') }
+ const context = writeContext(root, env)
+ assert.match(context.source, /^[a-f0-9]{40}$/)
+ assert.equal(context.yarn, '1.22.22')
+ assert(fs.readFileSync(output, 'utf8').includes(`playwright=${context.playwright}\n`))
+ assert(fs.readFileSync(envFile, 'utf8').includes(`PLAYWRIGHT_BROWSERS_PATH=${context.browserCache}\n`))
+ assert.equal(context.yarnCache, path.join(env.RUNNER_TEMP, 'artplayer-yarn-cache'))
+ fs.appendFileSync(path.join(root, 'yarn.lock'), '\n')
+ assert.notEqual(ciContext(root, env).lockSha256, context.lockSha256)
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
+ manifest.packageManager = 'npm@1.22.22'
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(manifest))
+ assert.throws(() => ciContext(root, env), { name: 'AssertionError' })
+})
+
+test('CI command returns real failure codes and preserves reports for success, failure and invalid JSON', (t) => {
+ const root = fixture(t)
+ const stepSummary = path.join(root, 'step-summary.md')
+ for (const [needs, expected] of [[JSON.stringify(success()), 0], [JSON.stringify({ ...success(), coverage: { result: 'cancelled' } }), 1], ['{bad', 1]]) {
+ const result = spawnSync(process.execPath, [path.resolve('scripts/ci-summary.mjs')], { cwd: root, encoding: 'utf8', env: { ...process.env, ARTPLAYER_CI_NEEDS: needs, GITHUB_STEP_SUMMARY: stepSummary } })
+ assert.equal(result.status, expected, result.stderr)
+ const report = JSON.parse(fs.readFileSync(path.join(root, 'refactor/.cache/ci/summary.json'), 'utf8'))
+ assert.equal(report.passed, expected === 0)
+ assert(fs.readFileSync(path.join(root, 'refactor/.cache/ci/summary.md'), 'utf8').includes(expected === 0 ? '**passed**' : '**failed**'))
+ }
+ assert(fs.readFileSync(stepSummary, 'utf8').includes('cancelled'))
+})