ci: [CI-01] add OS matrices, scoped caches and required result gate

This commit is contained in:
Harvey Zhao committed 2026-09-13 13:02:04 +08:00
1 parent 2c73e2b105
commit 585e11c53c
15 files changed
+677 -32

No files matched your search

+103 -22
View File
@@ -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
+5 -3
View File
@@ -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": {
@@ -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
}
}
@@ -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。
+48 -2
View File
@@ -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 隔离与启用条件
+1 -1
View File
@@ -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 前源码基线
+3 -2
View File
@@ -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<br>增强兼容矩阵、并发缓存与 CI 报告 | DOC-10, ENG-08, ENG-09, ENG-10 | OS/Node/TS/浏览器与影响范围矩阵、缓存、超时、汇总检查和 artifact 报告 | 固定安装、失败/取消不误报、核心影响全生态;检查只读,失败证据可追溯 | H | todo |
| 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-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 |
@@ -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)
+13
View File
@@ -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 指纹。
+77
View File
@@ -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'))))
+41
View File
@@ -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' })
})
}
+6 -2
View File
@@ -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",
+53
View File
@@ -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())))
+65
View File
@@ -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
}
+8
View File
@@ -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,
+81
View File
@@ -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 = '<img src=x onerror=alert(1)>'
assert(!renderSummary(evaluateJobs(needs)).includes('<img'))
})
function fixture(t) {
const root = fs.mkdtempSync(path.resolve('refactor/.cache/ci-summary-test-'))
for (const file of ['package.json', '.node-version', 'yarn.lock']) fs.copyFileSync(file, path.join(root, file))
t.after(() => {
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'))
})