mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: centralize workspace package version
This commit is contained in:
@@ -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/` 静态资源的 orchestrator,Vite 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 框架、状态库、请求库或测试框架。
|
||||
|
||||
@@ -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 只能消费该 output;Git 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`。
|
||||
- Good:CI 在 checkout tag 后读取 root version,quality output 传给 publish,publish 再校验 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 copy,orchestrator 复制真实 icons 并生成 root-version manifest。
|
||||
- 发布契约:读取真实 release workflow,验证 tag trigger、quality output、SHA artifact、版本 archive/OSS path 和 zip manifest version check。
|
||||
- 产物审计:构建后断言 manifest、icons、入口文件存在且 manifest version 等于 root version;watch smoke 可在缺少外部 URL 时明确跳过。
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
```sh
|
||||
# Wrong:发布版本由 tag 或子包值决定。
|
||||
release_version="${GITHUB_REF_NAME}"
|
||||
|
||||
# Correct:CI 只消费 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 P1–P3 |
|
||||
| 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 P1–P3 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,
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@trade-message-center/chrome-extension",
|
||||
"version": "0.7.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user