feat: centralize workspace package version

This commit is contained in:
YBF
2026-09-01 17:39:14 +08:00
parent aa79670529
commit d5e016df74
27 changed files with 780 additions and 84 deletions
+33 -8
View File
@@ -39,6 +39,8 @@ jobs:
quality:
name: generate
needs: verify-main-tag
outputs:
extension_version: ${{ steps.package-version.outputs.version }}
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production
@@ -67,6 +69,15 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Read and validate package version
id: package-version
shell: bash
run: |
set -euo pipefail
version="$(node scripts/package-version.mjs)"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
printf 'PACKAGE_VERSION=%s\n' "$version" >> "$GITHUB_ENV"
- name: Verify CI authorization environment
shell: bash
run: |
@@ -113,20 +124,21 @@ jobs:
shell: bash
run: |
set -euo pipefail
archive="$RUNNER_TEMP/chrome-extension.zip"
archive="$RUNNER_TEMP/$PACKAGE_VERSION.zip"
(
cd apps/chrome-extension/dist
zip -qr "$archive" .
)
cp "$archive" "$GITHUB_WORKSPACE/chrome-extension.zip"
test -s "$GITHUB_WORKSPACE/chrome-extension.zip"
archive_name="trade-message-center-chrome-extension-${PACKAGE_VERSION}.zip"
cp "$archive" "$GITHUB_WORKSPACE/$archive_name"
test -s "$GITHUB_WORKSPACE/$archive_name"
- name: Upload Chrome extension release artifact
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v4
with:
name: chrome-extension-${{ github.sha }}
path: chrome-extension.zip
path: trade-message-center-chrome-extension-${{ steps.package-version.outputs.version }}.zip
if-no-files-found: error
retention-days: 7
@@ -440,6 +452,7 @@ jobs:
permissions:
contents: read
env:
PACKAGE_VERSION: ${{ needs.quality.outputs.extension_version }}
OSS_BUCKET: sinanpilot-bucket
OSS_ENDPOINT: https://oss-cn-hangzhou.aliyuncs.com
OSS_REGION: cn-hangzhou
@@ -481,10 +494,22 @@ jobs:
: "${OSS_REGION:?OSS_REGION is required}"
: "${OSS_ACCESS_KEY_ID:?OSS_ACCESS_KEY_ID is required}"
: "${OSS_ACCESS_KEY_SECRET:?OSS_ACCESS_KEY_SECRET is required}"
archive="$RUNNER_TEMP/extension-artifact/chrome-extension.zip"
archive_name="trade-message-center-chrome-extension-${PACKAGE_VERSION}.zip"
archive="$RUNNER_TEMP/extension-artifact/$archive_name"
test -s "$archive"
release_version="${GITHUB_REF_NAME//\//-}"
archive_name="trade-message-center-chrome-extension-${release_version}.zip"
destination="oss://${OSS_BUCKET}/chrome-extension/${release_version}/${archive_name}"
manifest_version="$(unzip -p "$archive" manifest.json | node --input-type=module -e '
let source = "";
process.stdin.on("data", (chunk) => { source += chunk; });
process.stdin.on("end", () => {
const manifest = JSON.parse(source);
if (typeof manifest.version !== "string") process.exit(1);
process.stdout.write(manifest.version);
});
' )"
if [[ "$manifest_version" != "$PACKAGE_VERSION" ]]; then
echo "::error::Generated manifest version does not match the quality job version."
exit 1
fi
destination="oss://${OSS_BUCKET}/chrome-extension/${PACKAGE_VERSION}/${archive_name}"
"$OSSUTIL_PATH" cp --force "$archive" "$destination"
printf 'Published Chrome extension: %s\n' "$destination"
@@ -66,7 +66,7 @@ apps/chrome-extension/
└── src/
```
Popup 不归属于任一渠道业务目录。Vite 的 HTML input、TypeScript include 和 `public/manifest.json``action.default_popup` 必须同时指向构建前后对应的位置;移动入口时必须一起更新并通过构建产物检查。
Popup 不归属于任一渠道业务目录。Vite 的 HTML input、TypeScript include 和生成的 `dist/manifest.json``action.default_popup` 必须同时指向构建前后对应的位置;`manifest.template.json` 只提供不含版本的清单模板,移动入口时必须一起更新并通过构建产物检查。
## 新增代码检查
@@ -1,6 +1,6 @@
# Chrome 扩展目录结构
> 当前扩展由 Vite 把 `popup/``src/` 和 `public/` 构建到 `dist/`;Chrome 的“加载已解压扩展”应选择 `dist/`。
> 当前扩展由 Vite 把 `popup/``src/` 构建到共享 `dist/`,由扩展构建编排器复制 `public/` 静态资源并生成 manifest;Chrome 的“加载已解压扩展”应选择 `dist/`。
## 当前目录
@@ -10,13 +10,13 @@
apps/
└── chrome-extension/
├── package.json
├── manifest.template.json
├── public/
│ ├── icons/
│ │ ├── icon-16.png
│ │ ├── icon-32.png
│ │ ├── icon-48.png
│ │ └── icon-128.png
│ └── manifest.json
├── popup/
│ ├── popup.html
│ └── popup.ts
@@ -44,7 +44,7 @@ tsconfig.base.json
## 新增代码时
- 不要为了填满目录而提前创建 `src/` 或空的 `components/``hooks/``utils/` 目录。
- Popup 入口固定放在包根目录的 `popup/`,并在 `vite.config.ts` 的 Rollup input 中声明;Chrome 运行时路径必须与 `public/manifest.json` 一致
- Popup 入口固定放在包根目录的 `popup/`,并在 `vite.config.ts` 的 Rollup input 中声明;Chrome 运行时加载 `dist/manifest.json`,其 `action.default_popup` 必须与构建后的 Popup 路径一致。`manifest.template.json` 不含可漂移的 `version`,由构建编排器注入根 workspace 版本后生成 manifest
- 渠道业务分别放入 `src/global-sources/``src/onetalk/``src/made-in-china/`;通用浏览器基础能力放入 `src/lib/`。目录在首个真实实现落地时创建,不提前提交空目录。
- 业务模块按渠道聚拢代码,不要把三个渠道按 `components/``services/` 等文件类型打散混放。
- 具体依赖方向和 `lib` 准入条件遵循 [模块边界](./architecture.md)。
@@ -17,6 +17,13 @@
- 扩展与服务端的消息、配置和 API 数据在边界处定义类型,避免在多个组件中各自解释同一原始对象。
- 新增可交互 UI 时,至少验证键盘操作、错误状态和加载/空状态;具体测试工具确定后再把命令写入本文件。
## Workspace 版本与共享 dist
-`package.json:version` 是扩展发布版本唯一事实源;扩展包的 `version` 只作为由 `pnpm version:sync` 维护的 metadata 镜像。
- 扩展 build/watch 只能消费根 workspace 命令注入的 `TMC_PACKAGE_VERSION`;缺失或非法时必须失败,不得使用模板版本或旧 manifest。
- `manifest.template.json` 不声明 `version``scripts/build.mjs``scripts/dev.mjs` 是 generated `dist/manifest.json``public/` 静态资源的 orchestratorVite entry 必须设置 `build.copyPublicDir: false`,避免多个 watcher 竞争共享输出。
- 版本传播、manifest 生成、真实 icons 复制和 release workflow 命名分别由 `test/manifest.test.js``test/vite-config.test.js``test/release-version.test.js` 覆盖;生产产物应额外审计 manifest、icons 和入口文件。
## 禁止做法
- 未经任务说明直接引入 UI 框架、状态库、请求库或测试框架。
+68
View File
@@ -72,3 +72,71 @@ if (!buildHash) throw new Error("BUILD_HASH must be provided by the workspace co
// Correct:契约规定未设置时使用默认值,且 0 合法。
const retryLimit = options.retryLimit ?? DEFAULT_RETRY_LIMIT;
```
## Scenario: Workspace 发布版本与扩展清单
### 1. Scope / Trigger
- Trigger:根 package 版本跨 workspace package metadata、扩展构建/watch 和 release CI 传播,且这些边界不得各自补偿或推导版本。
- Scope:根 `package.json:version` 是唯一可编辑事实源;`apps/*/package.json` 的具体版本是同步镜像;扩展最终只加载生成的 `dist/manifest.json`
### 2. Signatures
- `validatePackageVersion(value: unknown, source?: string): string`:验证同时满足 package metadata 和 Chrome Manifest 的三段数字版本。
- `readRootPackageVersion(rootDir?: string): string`:只读取并验证 workspace 根 `package.json`
- `pnpm version:sync`:把根版本写入所有 `apps/*/package.json` 镜像。
- `pnpm version:check`:只检查镜像,不自动修复。
- `TMC_PACKAGE_VERSION`:由 workspace 根命令注入给下游扩展命令;扩展直接执行时缺失必须失败。
### 3. Contracts
- 版本必须是 `major.minor.patch` 三段纯数字;每段在 `0..65535`,无前导零,且不得为 `0.0.0`
- 禁止 prerelease、build metadata、`v` 前缀和第四段;root version、package mirrors、generated manifest、release archive/path 使用同一值。
- `BUILD_HASH` 仍由根 wrapper 独立生成;版本读取不能从 hash、Git tag 或任何子包 manifest 推导。
- Vite entry 不得复制共享 `dist` 的 public assets;扩展 build/dev orchestrator 只初始化一次静态资源,并独占 generated manifest。
- release quality job 输出版本,publish job 只能消费该 outputGit tag 仅负责触发、源码定位和 ancestry 校验。
### 4. Validation & Error Matrix
| 条件 | 行为 |
| --- | --- |
| 根版本缺失、非字符串或 JSON 无法读取 | 读取命令、质量命令和 CI 在使用前显式失败 |
| 版本不是三段数字、含前导零、额外段、prerelease/build metadata | `validatePackageVersion` 显式失败,不生成/保留可发布 manifest |
| 版本段大于 `65535` 或全为零 | 显式失败,不生成/保留可发布 manifest |
| 子包镜像缺失或与根版本不同 | `version:check` 和根质量命令失败;不隐式改写 |
| `TMC_PACKAGE_VERSION` 缺失或非法 | 直接扩展 build/watch 显式失败;不回退到模板或旧产物 |
| tag 与根版本不同 | CI 仍可按 tag 触发,但发布 archive/path 使用已校验的根版本 |
### 5. Good / Base / Bad Cases
- Good:修改根 `package.json:version` 后运行 `pnpm version:sync`,再由 `pnpm version:check` 和构建验证所有镜像与 `dist/manifest.json`
- GoodCI 在 checkout tag 后读取 root versionquality output 传给 publishpublish 再校验 zip 内 manifest version。
- Base`public/icons` 由扩展 orchestrator 复制一次,Vite watcher 使用 `copyPublicDir: false`
- Bad:从 `GITHUB_REF_NAME`、扩展 package manifest 或 `manifest.template.json` 作为发布版本来源。
- Bad:四个 watcher 各自 public-copy,或版本缺失时继续使用旧 `dist/manifest.json`
### 6. Tests Required
- 版本 reader/validator:覆盖合法交集、缺失、非字符串、格式、范围和全零失败,并断言稳定错误信息。
- 镜像同步:临时 workspace 中验证漂移失败、`version:sync` 修复、再次检查通过。
- 构建边界:验证 root wrapper 同时传递一次 `BUILD_HASH``TMC_PACKAGE_VERSION`Vite 禁止 public copyorchestrator 复制真实 icons 并生成 root-version manifest。
- 发布契约:读取真实 release workflow,验证 tag trigger、quality output、SHA artifact、版本 archive/OSS path 和 zip manifest version check。
- 产物审计:构建后断言 manifest、icons、入口文件存在且 manifest version 等于 root versionwatch smoke 可在缺少外部 URL 时明确跳过。
### 7. Wrong vs Correct
```sh
# Wrong:发布版本由 tag 或子包值决定。
release_version="${GITHUB_REF_NAME}"
# CorrectCI 只消费 quality job 已校验的根版本 output。
PACKAGE_VERSION="${{ needs.quality.outputs.extension_version }}"
```
```js
// Wrong:下层缺失时偷偷使用旧版本或本地 package 值。
const version = process.env.TMC_PACKAGE_VERSION || "0.1.0";
// Correct:必填版本缺失或非法时显式失败。
const version = validatePackageVersion(process.env.TMC_PACKAGE_VERSION, "TMC_PACKAGE_VERSION");
```
@@ -0,0 +1,43 @@
# Finding Ledger
## GPV-CHK-001
- Invariant: 每个 Vite entry 不得并发拥有共享 `dist` 的静态资源复制;manifest 与静态资源应由扩展 orchestrator 单一初始化。
- Severity: P1
- Locus: `apps/chrome-extension/vite.config.ts:47,59-62`; `apps/chrome-extension/scripts/dev.mjs:33-43`
- Classification: `blocking_local`
- Status: closed
- Owner: original `trellis-implement` worker
- Reproducer before repair: checker regression in `apps/chrome-extension/test/vite-config.test.js` observed `build.copyPublicDir === undefined`; four watch processes otherwise retained Vite public copying into the shared `dist`.
- Required repair: disable Vite public copying and make `build.mjs`/`dev.mjs` initialize the public static assets once, preserving generated `dist/manifest.json` ownership and existing icon paths. Update/add non-tautological tests, then rerun targeted checks.
- Repair evidence: implementation worker set `build.copyPublicDir: false`, added one-time icon copying to both orchestrators, and reported the regression test, typecheck, production build, formatting, artifact audit, and diff check as passed.
## GPV-CHK-002
- Invariant: extension frontend specs must reference the actual manifest template/generated artifact paths.
- Severity: P2
- Locus: `.trellis/spec/chrome-extension/frontend/directory-structure.md:19,47`; `.trellis/spec/chrome-extension/frontend/architecture.md:69`
- Classification: `non_blocking`
- Status: closed
- Owner: main agent during finish/spec-update phase
- Evidence before resolution: the implementation moved the source file from `public/manifest.json` to `manifest.template.json`, while the specs still named the deleted path.
- Resolution: updated the directory and architecture specs to document `manifest.template.json`, generated `dist/manifest.json`, orchestrator-owned public assets, and the `copyPublicDir: false` boundary.
## GPV-CHK-003
- Invariant: cross-layer version propagation should have executable automated coverage where practical.
- Severity: P2
- Locus: build/watch orchestration and release naming contract
- Classification: `non_blocking`
- Status: closed
- Owner: original `trellis-implement` worker for test-only coverage; main agent for final decision
- Evidence: added `apps/chrome-extension/test/release-version.test.js`, which reads the real release workflow and asserts tag trigger, root-version job output, versioned archive, SHA artifact handoff, manifest-version check, and versioned OSS destination. Existing tests cover root reader, wrapper injection, manifest writer, icons copy, and Vite copy ownership.
## GPV-CHK-004
- Invariant: finding ledger entries must have one authoritative status so repair rounds remain auditable.
- Severity: P2
- Classification: `out_of_scope`
- Status: closed
- Owner: main agent
- Evidence: removed the duplicate GPV-CHK-003 status and retained its final `closed` state after checker revalidation.
@@ -6,10 +6,10 @@ This is a large cross-layer request, so implementation is split by semantic owne
| ID | State | Owner and exact write scope | Input / output | Acceptance and dependency |
| --- | --- | --- | --- | --- |
| P1 | ready | `trellis-implement`; `scripts/package-version.mjs`, `scripts/sync-package-versions.mjs`, `scripts/check-package-versions.mjs`, `scripts/with-build-hash.mjs`, root `package.json`, private `apps/*/package.json`, version-source tests | Root version reader/validator, `version:sync`/`version:check`, and one injected `TMC_PACKAGE_VERSION` | Root version is the only source, child mirrors stay equal and drift fails; unblocks P2/P3 |
| P2 | blocked by P1 | Same implementation owner; `apps/chrome-extension/manifest.template.json`, `apps/chrome-extension/scripts/manifest.mjs`, `apps/chrome-extension/scripts/build.mjs`, `apps/chrome-extension/scripts/dev.mjs`, extension tests | Versionless manifest template and deterministic `dist/manifest.json` generation | Production and watch outputs contain root version without stale fallback; depends on P1's environment contract |
| P3 | blocked by P2 | Same implementation owner; `.github/workflows/release_ci.yml`, `README.md` | CI job output and root-version release documentation | Archive name/path use root version while tag trigger/checkout stays unchanged; depends on generated manifest contract |
| R1 | pending | `trellis-check`; no production writes, test-only fixes allowed | Independent review of final diff, artifacts, and quality evidence | All PRD/design acceptance criteria and required checks are reported; runs after P1P3 |
| P1 | accepted | `trellis-implement`; `scripts/package-version.mjs`, `scripts/sync-package-versions.mjs`, `scripts/check-package-versions.mjs`, `scripts/with-build-hash.mjs`, root `package.json`, private `apps/*/package.json`, version-source tests | Root version reader/validator, `version:sync`/`version:check`, and one injected `TMC_PACKAGE_VERSION` | Root version is the only source, child mirrors stay equal and drift fails; unblocks P2/P3 |
| P2 | accepted | Same implementation owner; `apps/chrome-extension/manifest.template.json`, `apps/chrome-extension/scripts/manifest.mjs`, `apps/chrome-extension/scripts/build.mjs`, `apps/chrome-extension/scripts/dev.mjs`, `apps/chrome-extension/vite.config.ts`, extension tests | Versionless manifest template, one-time public asset initialization, and deterministic `dist/manifest.json` generation | Production and watch outputs contain root version without stale fallback; `GPV-CHK-001` independently closed |
| P3 | accepted | Same implementation owner; `.github/workflows/release_ci.yml`, `README.md`, release contract test | CI job output and root-version release documentation | Archive name/path use root version while tag trigger/checkout stays unchanged; `GPV-CHK-003` independently closed |
| R1 | accepted | `trellis-check`; no production writes, test-only fixes allowed | Independent review of final diff, artifacts, and quality evidence | No open `blocking_local` findings; GPV-CHK-001 and GPV-CHK-003 closed; GPV-CHK-002 resolved during spec update |
The main session maintains these states and does not treat a progress message as acceptance. The implement worker may complete P1P3 in order, but must keep each write scope explicit and must not commit.
@@ -29,11 +29,11 @@
## Acceptance Criteria
- [ ] AC1. 根版本是唯一可编辑的 workspace 发布版本来源;`pnpm version:sync` 可更新所有 `apps/*/package.json` 镜像,根命令/CI 在镜像漂移时失败,且不存在可被当作事实源的第二个版本。
- [ ] AC2. 修改根 `package.json``version` 后,所有 package 元数据和扩展构建产物 `dist/manifest.json` 的版本与之完全一致。
- [ ] AC3. 开发 watch 和生产构建生成的 manifest 都使用根版本;CI 压缩包名称和 OSS 目标路径使用根版本,Git tag 触发/checkout 语义不变。
- [ ] AC4. 缺失、非法或 Chrome 不支持的版本会得到稳定、可观察的失败,不会静默使用旧版本或 fallback。
- [ ] AC5. 正常构建、开发 watch、相关测试和格式检查通过,不影响 `BUILD_HASH` 注入、扩展身份、协议/配置/存储版本。
- [x] AC1. 根版本是唯一可编辑的 workspace 发布版本来源;`pnpm version:sync` 可更新所有 `apps/*/package.json` 镜像,根命令/CI 在镜像漂移时失败,且不存在可被当作事实源的第二个版本。
- [x] AC2. 修改根 `package.json``version` 后,所有 package 元数据和扩展构建产物 `dist/manifest.json` 的版本与之完全一致。
- [x] AC3. 开发 watch 和生产构建生成的 manifest 都使用根版本;CI 压缩包名称和 OSS 目标路径使用根版本,Git tag 触发/checkout 语义不变。
- [x] AC4. 缺失、非法或 Chrome 不支持的版本会得到稳定、可观察的失败,不会静默使用旧版本或 fallback。
- [x] AC5. 正常构建、开发 watch、相关测试和格式检查通过,不影响 `BUILD_HASH` 注入、扩展身份、协议/配置/存储版本。
## Resolved Scope
@@ -3,7 +3,7 @@
"name": "global-package-version",
"title": "全局 package 版本接管插件版本",
"description": "",
"status": "planning",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
+15 -4
View File
@@ -62,7 +62,7 @@ MIND_PAGE_ORIGIN=http://127.0.0.1:7878
ONETALK_PLUGIN_ORIGINS=chrome-extension://ogdbffjakeeidblabkeakakdecfbcmlf
```
`apps/chrome-extension/.keys/extension-private-key.pem` 是本地生成的扩展私钥,权限应保持为 `600`,不会提交到 Git`manifest.json` 内置从该私钥导出的固定公钥,因此从不同目录加载 `apps/chrome-extension/dist/` 不会再导致扩展 ID 变化。当前固定 ID 是 `ogdbffjakeeidblabkeakakdecfbcmlf``ONETALK_PLUGIN_ORIGINS` 仍必须是这个扩展的精确 Origin,不能改成任意 Origin`MIND_PAGE_ORIGIN` 是浏览器访问 Bright 的页面 Origin,不是 mock 的监听地址。
`apps/chrome-extension/.keys/extension-private-key.pem` 是本地生成的扩展私钥,权限应保持为 `600`,不会提交到 Git生成的 `dist/manifest.json` 内置从该私钥导出的固定公钥,因此从不同目录加载 `apps/chrome-extension/dist/` 不会再导致扩展 ID 变化。当前固定 ID 是 `ogdbffjakeeidblabkeakakdecfbcmlf``ONETALK_PLUGIN_ORIGINS` 仍必须是这个扩展的精确 Origin,不能改成任意 Origin`MIND_PAGE_ORIGIN` 是浏览器访问 Bright 的页面 Origin,不是 mock 的监听地址。
开发 server 会输出 `[mind-auth][diagnostic]`,只包含 `binding/session` endpoint、授权 operation、`request_started/allowed/rejected/request_failed`、HTTP status 和稳定 code,可据此判断请求是否发出、Mind 是否返回以及最终拒绝原因;不包含 Cookie、binding、请求体或原始异常。扩展握手的 Origin 拒绝会出现在 `[onetalk][diagnostic]` 中。
@@ -112,6 +112,17 @@ VITE_BRIGHT_WEBSOCKET_URL=ws://127.0.0.1:7878/ws/plugin
只有以 `VITE_` 开头的变量会暴露给扩展代码,数据库连接串和 binding 等服务端敏感配置不得使用该前缀。
## Workspace 版本
根目录 `package.json``version` 是 workspace 发布版本的唯一编辑点。修改后执行以下命令,把所有 `apps/*/package.json` 的版本镜像同步并检查一致性:
```bash
pnpm version:sync
pnpm version:check
```
版本必须是 Chrome 扩展和 package metadata 都支持的三段数字版本,例如 `0.1.0`;不使用 prerelease、build metadata 或额外段。协议、配置、IndexedDB 和授权版本仍是独立概念,不随 workspace 版本修改。
## 启动 server 与数据库
首次启动或数据库结构有变化时,先执行迁移:
@@ -179,14 +190,14 @@ pnpm build
推送指向 `main` 历史的 Git tag 后,生成流程会在发布前检查中的同一次构建基础上打包扩展,并将版本包上传到 OSS:
```text
oss://<OSS_BUCKET>/chrome-extension/<tag>/trade-message-center-chrome-extension-<tag>.zip
oss://<OSS_BUCKET>/chrome-extension/<package-version>/trade-message-center-chrome-extension-<package-version>.zip
```
OSS Bucket、Endpoint 和 Region 已直接写在 `.github/workflows/ci.yml` 中。`production` Environment 只需要配置以下密钥:
OSS Bucket、Endpoint 和 Region 已直接写在 `.github/workflows/release_ci.yml` 中。`production` Environment 只需要配置以下密钥:
- Secrets`OSS_ACCESS_KEY_ID``OSS_ACCESS_KEY_SECRET`
上传账号只需要目标 Bucket 对应版本目录的写权限。tag 中的 `/` 会在版本目录和文件名中转换为 `-`
上传账号只需要目标 Bucket 对应版本目录的写权限。Git tag 仍用于触发发布和定位源码,但不会作为扩展发布版本;版本目录和文件名使用根 `package.json``version`。现有同版本覆盖语义保持不变
## Server 数据库迁移
@@ -1,7 +1,6 @@
{
"manifest_version": 3,
"name": "Trade Message Center",
"version": "0.5.0",
"description": "Sync authorized Alibaba, Made in China, and Global Sources conversations to TradeBridge.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAivaB+t2BWMm2GzZ9Coa+Z9dFL26hZDi5LPOnDtIIDL52Blij1WgJ+ucB2u2IVs3KbzIz60xlqULwIuXfw5UpF8dcy3U/HhPWxzwGDyCThA59VTO/yRPLLXmaqBtPEvOS7UT2sqzR9NZD/MGhfJ5Ms8BI6gXTx+1ODswrLFDElwAvG6Qug2J5xVabfmmu0jc2edz4rWMN99tRC3203RtbQ48mmjcG1MzAq+jcz52GPkVlgirdRmjRrAqsrJOi9E0iWHVg6DNx/1LoHcYf6V+3lRmKFj4yZCQ2GZppCdBirqC1yFNsxQbRc0Z2ARvrd86TQjJ4LLgVgjNWZepLitbotwIDAQAB",
"icons": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/chrome-extension",
"version": "0.7.0",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
+28 -5
View File
@@ -1,12 +1,17 @@
// 编排扩展生产构建与版本清单
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { validatePackageVersion } from "../../../scripts/package-version.mjs";
import { copyPublicAssets, removeGeneratedManifest, writeManifest } from "./manifest.mjs";
const packageDir = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const buildEntries = ["popup", "main-page", "isolated-content", "service-worker"];
const viteCommand = process.platform === "win32" ? "vite.cmd" : "vite";
for (const entry of buildEntries) {
const runViteBuild = (entry) => {
const result = spawnSync(viteCommand, ["build", "--mode", "production"], {
cwd: packageDir,
env: { ...process.env, TMC_EXTENSION_ENTRY: entry },
@@ -14,8 +19,26 @@ for (const entry of buildEntries) {
});
if (result.error) throw result.error;
if (result.status !== 0) {
process.exitCode = result.status ?? 1;
break;
return result.status === 0;
};
/** 生成完整的生产扩展产物。 */
const runProductionBuild = () => {
removeGeneratedManifest(packageDir);
const packageVersion = validatePackageVersion(
process.env.TMC_PACKAGE_VERSION,
"TMC_PACKAGE_VERSION",
);
for (const entry of buildEntries) {
if (!runViteBuild(entry)) {
process.exitCode = 1;
return;
}
}
}
copyPublicAssets(packageDir);
writeManifest({ packageDir, version: packageVersion });
};
runProductionBuild();
+76 -33
View File
@@ -1,46 +1,89 @@
import { rmSync } from "node:fs";
// 编排扩展开发监视与版本清单
import { rmSync, watch } from "node:fs";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
readRootPackageVersion,
validatePackageVersion,
} from "../../../scripts/package-version.mjs";
import { copyPublicAssets, removeGeneratedManifest, writeManifest } from "./manifest.mjs";
const packageDir = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const distDir = resolve(packageDir, "dist");
const workspaceDir = resolve(packageDir, "../..");
const rootPackagePath = resolve(workspaceDir, "package.json");
const buildEntries = ["popup", "main-page", "isolated-content", "service-worker"];
const viteCommand = process.platform === "win32" ? "vite.cmd" : "vite";
rmSync(distDir, { force: true, recursive: true });
/** 启动共享 dist 上的扩展开发监视流程。 */
const runDevelopmentWatch = () => {
rmSync(distDir, { force: true, recursive: true });
const packageVersion = validatePackageVersion(
process.env.TMC_PACKAGE_VERSION,
"TMC_PACKAGE_VERSION",
);
copyPublicAssets(packageDir);
writeManifest({ packageDir, version: packageVersion });
let shuttingDown = false;
const children = buildEntries.map((entry) =>
spawn(viteCommand, ["build", "--watch", "--mode", "development"], {
cwd: packageDir,
env: {
...process.env,
TMC_EXTENSION_DEV_WATCH: "1",
TMC_EXTENSION_ENTRY: entry,
},
stdio: "inherit",
}),
);
let shuttingDown = false;
let versionChangeTimer;
let versionWatcher;
const children = buildEntries.map((entry) =>
spawn(viteCommand, ["build", "--watch", "--mode", "development"], {
cwd: packageDir,
env: {
...process.env,
TMC_EXTENSION_DEV_WATCH: "1",
TMC_EXTENSION_ENTRY: entry,
},
stdio: "inherit",
}),
);
const stopChildren = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) child.kill(signal);
const stopChildren = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
versionWatcher?.close();
if (versionChangeTimer) clearTimeout(versionChangeTimer);
for (const child of children) child.kill(signal);
};
const refreshManifestVersion = () => {
try {
const currentVersion = readRootPackageVersion(workspaceDir);
writeManifest({ packageDir, version: currentVersion });
} catch (error) {
removeGeneratedManifest(packageDir);
console.error(error);
process.exitCode = 1;
stopChildren("SIGTERM");
}
};
versionWatcher = watch(rootPackagePath, () => {
if (shuttingDown) return;
if (versionChangeTimer) clearTimeout(versionChangeTimer);
versionChangeTimer = setTimeout(refreshManifestVersion, 50);
});
for (const child of children) {
child.on("error", (error) => {
console.error(error);
process.exitCode = 1;
stopChildren("SIGTERM");
});
child.on("exit", (code) => {
if (shuttingDown) return;
process.exitCode = code ?? 1;
stopChildren("SIGTERM");
});
}
process.once("SIGINT", () => stopChildren("SIGINT"));
process.once("SIGTERM", () => stopChildren("SIGTERM"));
};
for (const child of children) {
child.on("error", (error) => {
console.error(error);
process.exitCode = 1;
stopChildren("SIGTERM");
});
child.on("exit", (code) => {
if (shuttingDown) return;
process.exitCode = code ?? 1;
stopChildren("SIGTERM");
});
}
process.once("SIGINT", () => stopChildren("SIGINT"));
process.once("SIGTERM", () => stopChildren("SIGTERM"));
runDevelopmentWatch();
@@ -0,0 +1,65 @@
// 生成扩展清单与静态资源
import { cpSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { validatePackageVersion } from "../../../scripts/package-version.mjs";
const extensionDir = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const readTemplate = (templatePath) => {
let template;
try {
template = JSON.parse(readFileSync(templatePath, "utf8"));
} catch (error) {
throw new Error(`Unable to read manifest template ${templatePath}`, { cause: error });
}
if (template === null || typeof template !== "object" || Array.isArray(template)) {
throw new Error("Manifest template must contain a JSON object");
}
if (Object.hasOwn(template, "version")) {
throw new Error("Manifest template must not define version");
}
return template;
};
const generatedManifestPath = (packageDir) => resolve(packageDir, "dist", "manifest.json");
/** 删除不可发布的旧扩展清单。 */
export const removeGeneratedManifest = (packageDir = extensionDir) => {
rmSync(generatedManifestPath(packageDir), { force: true });
};
/** 初始化扩展构建所需的公共静态资源。 */
export const copyPublicAssets = (packageDir = extensionDir) => {
cpSync(resolve(packageDir, "public"), resolve(packageDir, "dist"), {
force: true,
recursive: true,
});
};
/** 原子生成带根版本的扩展清单。 */
export const writeManifest = ({
packageDir = extensionDir,
version = process.env.TMC_PACKAGE_VERSION,
} = {}) => {
const packageVersion = validatePackageVersion(version, "TMC_PACKAGE_VERSION");
const template = readTemplate(resolve(packageDir, "manifest.template.json"));
const outputPath = generatedManifestPath(packageDir);
const temporaryPath = `${outputPath}.tmp-${process.pid}`;
mkdirSync(resolve(packageDir, "dist"), { recursive: true });
try {
writeFileSync(
temporaryPath,
`${JSON.stringify({ ...template, version: packageVersion }, null, 4)}\n`,
);
renameSync(temporaryPath, outputPath);
} catch (error) {
rmSync(temporaryPath, { force: true });
throw new Error(`Unable to write generated manifest ${outputPath}`, { cause: error });
}
return outputPath;
};
@@ -5,7 +5,7 @@ import { createHash, createPublicKey } from "node:crypto";
import { readFile } from "node:fs/promises";
import test from "node:test";
const manifestUrl = new URL("../public/manifest.json", import.meta.url);
const manifestUrl = new URL("../manifest.template.json", import.meta.url);
const expectedExtensionId = "ogdbffjakeeidblabkeakakdecfbcmlf";
const extensionIdFromPublicKey = (key) => {
@@ -18,6 +18,7 @@ const extensionIdFromPublicKey = (key) => {
test("keeps a fixed Chrome extension identity", async () => {
const manifest = JSON.parse(await readFile(manifestUrl, "utf8"));
assert.equal("version" in manifest, false);
assert.equal(typeof manifest.key, "string");
createPublicKey({
key: Buffer.from(manifest.key, "base64"),
@@ -0,0 +1,70 @@
// 验证扩展清单的版本注入与身份字段
import assert from "node:assert/strict";
import { cp, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { readRootPackageVersion } from "../../../scripts/package-version.mjs";
import { copyPublicAssets, removeGeneratedManifest, writeManifest } from "../scripts/manifest.mjs";
const templateUrl = new URL("../manifest.template.json", import.meta.url);
const createFixture = async () => {
const packageDir = await mkdtemp(join(tmpdir(), "tmc-manifest-"));
await cp(templateUrl, join(packageDir, "manifest.template.json"));
return packageDir;
};
test("template has no version and generated manifest uses the injected version", async () => {
const packageDir = await createFixture();
const template = JSON.parse(await readFile(join(packageDir, "manifest.template.json"), "utf8"));
assert.equal("version" in template, false);
const outputPath = writeManifest({ packageDir, version: "1.2.3" });
const manifest = JSON.parse(await readFile(outputPath, "utf8"));
assert.equal(manifest.version, "1.2.3");
assert.equal(manifest.key, template.key);
assert.deepEqual(manifest.permissions, template.permissions);
assert.deepEqual(manifest.background, template.background);
assert.deepEqual(manifest.action, template.action);
});
test("generates the manifest with the actual workspace root version", async () => {
const packageDir = await createFixture();
const rootVersion = readRootPackageVersion();
const outputPath = writeManifest({ packageDir, version: rootVersion });
const manifest = JSON.parse(await readFile(outputPath, "utf8"));
assert.equal(manifest.version, rootVersion);
});
test("copies the actual public icons into the generated extension output", async () => {
const packageDir = await createFixture();
await cp(new URL("../public/", import.meta.url), join(packageDir, "public"), {
recursive: true,
});
copyPublicAssets(packageDir);
for (const iconName of ["icon-16.png", "icon-32.png", "icon-48.png", "icon-128.png"]) {
const source = await readFile(new URL(`../public/icons/${iconName}`, import.meta.url));
const copied = await readFile(join(packageDir, "dist", "icons", iconName));
assert.deepEqual(copied, source);
}
});
test("invalid or missing injected versions fail without writing a manifest", async () => {
const packageDir = await createFixture();
const oldManifestPath = join(packageDir, "dist", "manifest.json");
await mkdir(join(packageDir, "dist"), { recursive: true });
await writeFile(oldManifestPath, JSON.stringify({ version: "0.1.0" }));
for (const version of [undefined, "1.2.3-beta.1", "65536.0.0"]) {
assert.throws(() => writeManifest({ packageDir, version }), /TMC_PACKAGE_VERSION/u);
}
removeGeneratedManifest(packageDir);
await assert.rejects(readFile(oldManifestPath), { code: "ENOENT" });
});
@@ -8,7 +8,7 @@ const popupHtmlUrl = new URL("../popup/popup.html", import.meta.url);
const popupSourceUrl = new URL("../popup/popup.ts", import.meta.url);
const buildConfigUrl = new URL("../src/onetalk/build-config.ts", import.meta.url);
const viteConfigUrl = new URL("../vite.config.ts", import.meta.url);
const manifestUrl = new URL("../public/manifest.json", import.meta.url);
const manifestUrl = new URL("../manifest.template.json", import.meta.url);
test("keeps Binding in the identity section and Bright details development-only", async () => {
const html = await readFile(popupHtmlUrl, "utf8");
@@ -0,0 +1,86 @@
// 验证 release workflow 的根版本发布契约
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const workflowUrl = new URL("../../../.github/workflows/release_ci.yml", import.meta.url);
const blockFromHeading = (source, heading) => {
const lines = source.split("\n");
const start = lines.findIndex((line) => line === heading);
assert.notEqual(start, -1, `Missing workflow heading: ${heading}`);
const indentation = heading.length - heading.trimStart().length;
let end = start + 1;
while (end < lines.length) {
const line = lines[end];
const lineIndentation = line.length - line.trimStart().length;
if (line.trim() !== "" && lineIndentation <= indentation) break;
end += 1;
}
return lines.slice(start, end).join("\n");
};
const readWorkflow = () => readFile(workflowUrl, "utf8");
test("keeps releases tag-only and reads the root version after checkout", async () => {
const workflow = await readWorkflow();
const trigger = blockFromHeading(workflow, "on:");
const quality = blockFromHeading(workflow, " quality:");
assert.match(trigger, /on:\n\s+push:\n\s+tags:\n\s+- "\*\*"/u);
assert.doesNotMatch(trigger, /pull_request|workflow_dispatch|branches/u);
const checkoutIndex = quality.indexOf("- name: Checkout");
const versionIndex = quality.indexOf("- name: Read and validate package version");
assert.ok(checkoutIndex >= 0);
assert.ok(versionIndex > checkoutIndex);
assert.match(quality, /id: package-version/u);
assert.match(quality, /version="\$\(node scripts\/package-version\.mjs\)"/u);
assert.match(
quality,
/extension_version:\s+\$\{\{ steps\.package-version\.outputs\.version \}\}/u,
);
assert.match(quality, /version=%s\\n.*GITHUB_OUTPUT/u);
assert.match(quality, /PACKAGE_VERSION=%s\\n.*GITHUB_ENV/u);
});
test("names the quality archive from the validated root version", async () => {
const quality = blockFromHeading(await readWorkflow(), " quality:");
assert.match(quality, /archive="\$RUNNER_TEMP\/\$PACKAGE_VERSION\.zip"/u);
assert.match(
quality,
/archive_name="trade-message-center-chrome-extension-\$\{PACKAGE_VERSION\}\.zip"/u,
);
assert.match(quality, /cp "\$archive" "\$GITHUB_WORKSPACE\/\$archive_name"/u);
assert.match(
quality,
/path: trade-message-center-chrome-extension-\$\{\{ steps\.package-version\.outputs\.version \}\}\.zip/u,
);
});
test("publishes the SHA artifact under the quality job version path", async () => {
const publish = blockFromHeading(await readWorkflow(), " publish-extension:");
assert.match(publish, /needs: \[quality, postgres-integration\]/u);
assert.match(
publish,
/PACKAGE_VERSION:\s+\$\{\{ needs\.quality\.outputs\.extension_version \}\}/u,
);
assert.match(publish, /name: chrome-extension-\$\{\{ github\.sha \}\}/u);
assert.match(
publish,
/archive_name="trade-message-center-chrome-extension-\$\{PACKAGE_VERSION\}\.zip"/u,
);
assert.match(
publish,
/destination="oss:\/\/\$\{OSS_BUCKET\}\/chrome-extension\/\$\{PACKAGE_VERSION\}\/\$\{archive_name\}"/u,
);
assert.match(publish, /unzip -p "\$archive" manifest\.json/u);
assert.match(publish, /typeof manifest\.version !== "string"/u);
assert.match(publish, /\[\[ "\$manifest_version" != "\$PACKAGE_VERSION" \]\]/u);
assert.doesNotMatch(publish, /GITHUB_REF_NAME|release_version/u);
});
+44 -8
View File
@@ -1,4 +1,4 @@
// 验证 workspace 构建 hash 的生成与传递
// 验证 workspace 构建标识与版本传递
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
@@ -25,7 +25,7 @@ test("requires a Bright WebSocket URL in a Vite build environment", () => {
);
});
const readChildBuildHash = (parentBuildHash) => {
const readChildWorkspaceValues = (parentBuildHash) => {
const environment = { ...process.env };
if (parentBuildHash === undefined) delete environment.BUILD_HASH;
else environment.BUILD_HASH = parentBuildHash;
@@ -39,23 +39,27 @@ const readChildBuildHash = (parentBuildHash) => {
"node",
"--input-type=module",
"-e",
'process.stdout.write(`child-hash=${process.env.BUILD_HASH ?? ""}`)',
'process.stdout.write(`child-hash=${process.env.BUILD_HASH ?? ""};child-version=${process.env.TMC_PACKAGE_VERSION ?? ""}`)',
],
{ cwd: workspaceDir, encoding: "utf8", env: environment },
);
assert.equal(result.status, 0, result.stderr);
const match = /child-hash=([^\r\n]*)/.exec(result.stdout);
const match = /child-hash=([^;\r\n]*);child-version=([^\r\n]*)/.exec(result.stdout);
assert.ok(match, result.stdout);
return match[1];
return { buildHash: match[1], packageVersion: match[2] };
};
test("generates one workspace hash and passes it to child commands", () => {
assert.match(readChildBuildHash(), /^[0-9a-f]{16}$/);
const values = readChildWorkspaceValues();
assert.match(values.buildHash, /^[0-9a-f]{16}$/);
assert.equal(values.packageVersion, "0.1.0");
});
test("does not forward a parent hash over the workspace-generated hash", () => {
assert.match(readChildBuildHash("parent-hash"), /^[0-9a-f]{16}$/);
const values = readChildWorkspaceValues("parent-hash");
assert.match(values.buildHash, /^[0-9a-f]{16}$/);
assert.equal(values.packageVersion, "0.1.0");
});
test("uses the injected workspace hash in development Vite configuration", () => {
@@ -115,11 +119,16 @@ test("keeps production Vite defaults for minification and source maps", () => {
});
test("keeps production Vite mode separate from extension entry selection", async () => {
const buildScript = await readFile(new URL("../scripts/build.mjs", import.meta.url), "utf8");
const [buildScript, devScript] = await Promise.all([
readFile(new URL("../scripts/build.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/dev.mjs", import.meta.url), "utf8"),
]);
assert.match(buildScript, /const buildEntries = \[/u);
assert.match(buildScript, /\["build", "--mode", "production"\]/u);
assert.match(buildScript, /TMC_EXTENSION_ENTRY: entry/u);
assert.match(buildScript, /copyPublicAssets\(packageDir\)/u);
assert.match(devScript, /copyPublicAssets\(packageDir\)/u);
});
test("keeps development watchers from clearing shared output", () => {
@@ -154,6 +163,33 @@ test("keeps development watchers from clearing shared output", () => {
}
});
test("does not let Vite copy public assets from concurrent shared-output watchers", () => {
const previousBuildHash = process.env.BUILD_HASH;
const previousDevWatch = process.env.TMC_EXTENSION_DEV_WATCH;
const previousBuildEntry = process.env.TMC_EXTENSION_ENTRY;
process.env.BUILD_HASH = "workspace-test-hash";
process.env.TMC_EXTENSION_DEV_WATCH = "1";
process.env.TMC_EXTENSION_ENTRY = "main-page";
try {
const resolvedConfig = viteConfig({
command: "build",
mode: "development",
isSsrBuild: false,
isPreview: false,
});
assert.equal(resolvedConfig.build.copyPublicDir, false);
} finally {
if (previousBuildHash === undefined) delete process.env.BUILD_HASH;
else process.env.BUILD_HASH = previousBuildHash;
if (previousDevWatch === undefined) delete process.env.TMC_EXTENSION_DEV_WATCH;
else process.env.TMC_EXTENSION_DEV_WATCH = previousDevWatch;
if (previousBuildEntry === undefined) delete process.env.TMC_EXTENSION_ENTRY;
else process.env.TMC_EXTENSION_ENTRY = previousBuildEntry;
}
});
test("builds isolated content as a standalone IIFE", () => {
const previousBuildHash = process.env.BUILD_HASH;
const previousBuildEntry = process.env.TMC_EXTENSION_ENTRY;
+1
View File
@@ -58,6 +58,7 @@ export default defineConfig(({ mode }) => {
],
build: {
outDir: resolve(configDir, "dist"),
copyPublicDir: false,
emptyOutDir: !isDevelopmentWatch && (buildEntry === "popup" || mode === "development"),
...(mode === "development" ? { minify: false, sourcemap: true } : {}),
rollupOptions: {
+8 -6
View File
@@ -7,15 +7,17 @@
],
"type": "module",
"scripts": {
"dev": "node scripts/with-build-hash.mjs --parallel --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run dev",
"dev": "pnpm run version:check && node scripts/with-build-hash.mjs --parallel --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run dev",
"dev:server": "pnpm --filter @trade-message-center/server run dev:watch",
"dev:extension": "node scripts/with-build-hash.mjs --filter @trade-message-center/chrome-extension run dev",
"dev:extension": "pnpm run version:check && node scripts/with-build-hash.mjs --filter @trade-message-center/chrome-extension run dev",
"dev:mock": "pnpm --filter @trade-message-center/mind-http-mock run dev",
"build": "node scripts/with-build-hash.mjs --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run build",
"typecheck": "pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run typecheck",
"test": "pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run test",
"build": "pnpm run version:check && node scripts/with-build-hash.mjs --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run build",
"typecheck": "pnpm run version:check && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run typecheck",
"test": "pnpm run version:check && node --test scripts/package-version.test.mjs && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --filter @trade-message-center/mind-http-mock --if-present run test",
"format": "oxfmt",
"format:check": "oxfmt --check",
"format:check": "pnpm run version:check && oxfmt --check",
"version:sync": "node scripts/sync-package-versions.mjs",
"version:check": "node scripts/check-package-versions.mjs",
"prepare": "husky"
},
"devDependencies": {
+37
View File
@@ -0,0 +1,37 @@
// 检查 workspace 子包的根版本镜像
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { listWorkspacePackagePaths, readRootPackageVersion } from "./package-version.mjs";
const workspaceDir = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const readPackageVersion = (packagePath) => {
let manifest;
try {
manifest = JSON.parse(readFileSync(packagePath, "utf8"));
} catch (error) {
throw new Error(`Unable to read workspace package ${packagePath}`, { cause: error });
}
return manifest?.version;
};
/** 确认所有 workspace package metadata 都是根版本的镜像。 */
export const checkPackageVersions = (rootDir = workspaceDir) => {
const rootVersion = readRootPackageVersion(rootDir);
for (const packagePath of listWorkspacePackagePaths(rootDir)) {
const packageVersion = readPackageVersion(packagePath);
if (packageVersion !== rootVersion) {
throw new Error(
`Workspace package ${packagePath} version must equal root version ${rootVersion}`,
);
}
}
console.log(`All workspace package versions match ${rootVersion}`);
};
if (process.argv[1] === fileURLToPath(import.meta.url)) checkPackageVersions();
+69
View File
@@ -0,0 +1,69 @@
// 读取并校验 workspace 根版本
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const workspaceDir = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const packageFileName = "package.json";
const packageVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
const MAX_CHROME_VERSION_COMPONENT = 65535;
const readJsonFile = (filePath) => {
let source;
try {
source = readFileSync(filePath, "utf8");
} catch (error) {
throw new Error(`Unable to read ${filePath}`, { cause: error });
}
try {
return JSON.parse(source);
} catch (error) {
throw new Error(`Invalid JSON in ${filePath}`, { cause: error });
}
};
/** 枚举 apps 下可参与版本镜像的 workspace package。 */
export const listWorkspacePackagePaths = (rootDir = workspaceDir) =>
readdirSync(resolve(rootDir, "apps"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => resolve(rootDir, "apps", entry.name, "package.json"))
.filter((packagePath) => existsSync(packagePath))
.sort();
/** 校验同时满足 npm 与 Chrome Manifest 的三段版本。 */
export const validatePackageVersion = (version, source = "package.json") => {
if (typeof version !== "string" || version.length === 0) {
throw new Error(`${source} version must be a non-empty string`);
}
if (!packageVersionPattern.test(version)) {
throw new Error(
`${source} version must be a three-component numeric SemVer without prerelease or build metadata`,
);
}
const components = version.split(".").map(Number);
if (components.some((component) => component > MAX_CHROME_VERSION_COMPONENT)) {
throw new Error(`${source} version components must be between 0 and 65535`);
}
if (components.every((component) => component === 0)) {
throw new Error(`${source} version must not be 0.0.0`);
}
return version;
};
/** 读取唯一的 workspace 根版本事实源。 */
export const readRootPackageVersion = (rootDir = workspaceDir) => {
const packagePath = resolve(rootDir, packageFileName);
const manifest = readJsonFile(packagePath);
return validatePackageVersion(manifest?.version, `${packagePath}`);
};
/** 输出供 CI 和 shell 命令消费的根版本。 */
const printPackageVersion = () => {
console.log(readRootPackageVersion());
};
if (process.argv[1] === fileURLToPath(import.meta.url)) printPackageVersion();
+64
View File
@@ -0,0 +1,64 @@
// 验证 workspace 根版本与子包镜像
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { readRootPackageVersion, validatePackageVersion } from "./package-version.mjs";
import { checkPackageVersions } from "./check-package-versions.mjs";
import { syncPackageVersions } from "./sync-package-versions.mjs";
const createWorkspace = (rootVersion, packageVersions) => {
const rootDir = mkdtempSync(join(tmpdir(), "tmc-package-version-"));
mkdirSync(join(rootDir, "apps"));
writeFileSync(
join(rootDir, "package.json"),
`${JSON.stringify({ name: "fixture", version: rootVersion }, null, 4)}\n`,
);
for (const [name, version] of Object.entries(packageVersions)) {
const packageDir = join(rootDir, "apps", name);
mkdirSync(packageDir);
writeFileSync(
join(packageDir, "package.json"),
`${JSON.stringify({ name, version }, null, 4)}\n`,
);
}
return rootDir;
};
test("accepts the npm and Chrome version intersection", () => {
for (const version of ["0.1.0", "1.2.3", "65535.65535.65535"]) {
assert.equal(validatePackageVersion(version), version);
}
});
test("rejects missing, malformed, and Chrome-incompatible versions", () => {
const invalidVersions = [
[undefined, /fixture version must be a non-empty string/u],
[null, /fixture version must be a non-empty string/u],
["", /fixture version must be a non-empty string/u],
["1", /fixture version must be a three-component numeric SemVer/u],
["1.2", /fixture version must be a three-component numeric SemVer/u],
["1.2.3.4", /fixture version must be a three-component numeric SemVer/u],
["01.2.3", /fixture version must be a three-component numeric SemVer/u],
["1.2.3-beta.1", /fixture version must be a three-component numeric SemVer/u],
["1.2.3+build.1", /fixture version must be a three-component numeric SemVer/u],
["0.0.0", /fixture version must not be 0\.0\.0/u],
["65536.0.0", /fixture version components must be between 0 and 65535/u],
];
for (const [version, errorPattern] of invalidVersions) {
assert.throws(() => validatePackageVersion(version, "fixture"), errorPattern);
}
});
test("syncs and checks every app package against the root version", () => {
const rootDir = createWorkspace("1.2.3", { extension: "0.1.0", server: "0.1.0" });
assert.throws(() => checkPackageVersions(rootDir), /must equal root version 1.2.3/u);
syncPackageVersions(rootDir);
assert.equal(readRootPackageVersion(rootDir), "1.2.3");
checkPackageVersions(rootDir);
});
+39
View File
@@ -0,0 +1,39 @@
// 同步 workspace 子包的根版本镜像
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { listWorkspacePackagePaths, readRootPackageVersion } from "./package-version.mjs";
const workspaceDir = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const readPackageManifest = (packagePath) => {
try {
return JSON.parse(readFileSync(packagePath, "utf8"));
} catch (error) {
throw new Error(`Unable to read workspace package ${packagePath}`, { cause: error });
}
};
/** 将根版本写入所有 workspace package metadata 镜像。 */
export const syncPackageVersions = (rootDir = workspaceDir) => {
const rootVersion = readRootPackageVersion(rootDir);
const changedPackages = [];
for (const packagePath of listWorkspacePackagePaths(rootDir)) {
const manifest = readPackageManifest(packagePath);
if (manifest.version === rootVersion) continue;
manifest.version = rootVersion;
writeFileSync(packagePath, `${JSON.stringify(manifest, null, 4)}\n`);
changedPackages.push(packagePath);
}
console.log(
changedPackages.length === 0
? `Workspace package versions already match ${rootVersion}`
: `Synchronized ${changedPackages.length} workspace package version mirror(s) to ${rootVersion}`,
);
};
if (process.argv[1] === fileURLToPath(import.meta.url)) syncPackageVersions();
+9 -2
View File
@@ -1,10 +1,12 @@
// 生成并传递 workspace 共享构建 hash
// 生成并传递 workspace 共享构建标识
import { randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { readRootPackageVersion } from "./package-version.mjs";
const workspaceDir = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const packageManager = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
@@ -18,11 +20,16 @@ const runWorkspaceCommand = () => {
}
const buildHash = randomBytes(8).toString("hex");
const packageVersion = readRootPackageVersion(workspaceDir);
console.info(`Build hash: ${buildHash}`);
const result = spawnSync(packageManager, commandArguments, {
cwd: workspaceDir,
env: { ...process.env, BUILD_HASH: buildHash },
env: {
...process.env,
BUILD_HASH: buildHash,
TMC_PACKAGE_VERSION: packageVersion,
},
stdio: "inherit",
});