diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index ba2e03d..5020380 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,6 +6,33 @@ runs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json + - name: 定位下载缓存目录 + id: cache-paths + shell: bash + run: | + bun run - <<'JS' >> "$GITHUB_OUTPUT" + const { homedir } = require('node:os'); + const { join } = require('node:path'); + console.log(`bun=${process.env.BUN_INSTALL_CACHE_DIR || join(homedir(), '.bun', 'install', 'cache')}`); + console.log(`hutch=${process.env.HUTCH_HOME || process.env.DASH_HOME || join(homedir(), '.hutch')}`); + JS + - name: 缓存 Bun 依赖下载 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.cache-paths.outputs.bun }} + key: bun-download-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'bun.lock') }} + restore-keys: | + bun-download-v1-${{ runner.os }}-${{ runner.arch }}- + - name: 缓存 Hutch 与 Electrobun 工具链 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ steps.cache-paths.outputs.hutch }}/npm + ${{ steps.cache-paths.outputs.hutch }}/releases + ${{ steps.cache-paths.outputs.hutch }}/toolchains + key: desktop-tools-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'bun.lock', 'apps/desktop/package.json', 'apps/desktop/hutch.config.ts') }} + restore-keys: | + desktop-tools-v1-${{ runner.os }}-${{ runner.arch }}- - name: 从根目录安装依赖 shell: bash env: diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml new file mode 100644 index 0000000..8742d10 --- /dev/null +++ b/.github/workflows/build-desktop.yml @@ -0,0 +1,108 @@ +name: 桌面安装包构建与验收 + +on: + workflow_call: + inputs: + sign: + description: 使用传入的签名凭据,仅发布流程开启 + type: boolean + default: false + secrets: + MAC_CSC_LINK: + required: false + MAC_CSC_KEY_PASSWORD: + required: false + MAC_DEVELOPER_ID: + required: false + APPLE_ID: + required: false + APPLE_APP_SPECIFIC_PASSWORD: + required: false + APPLE_TEAM_ID: + required: false + +permissions: + contents: read + +jobs: + build: + name: 构建 ${{ matrix.platform }} ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + include: + - os: windows-2022 + platform: win + arch: x64 + - os: ubuntu-24.04 + platform: linux + arch: x64 + - os: macos-15 + platform: mac + arch: arm64 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: 校验 tag 与桌面版本 + if: github.ref_type == 'tag' + run: | + bun run - <<'JS' + const { version } = require('./apps/desktop/package.json'); + if (process.env.GITHUB_REF_NAME !== `desktop-v${version}`) { + throw new Error(`tag 必须为 desktop-v${version}`); + } + JS + - name: 导入 macOS 签名证书 + if: inputs.sign && runner.os == 'macOS' + env: + MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + run: | + if [[ -z "$MAC_CSC_LINK" ]]; then exit 0; fi + python3 - <<'PYTHON' + import base64, os, pathlib, urllib.request + value = os.environ['MAC_CSC_LINK'] + data = urllib.request.urlopen(value).read() if value.startswith('https://') else base64.b64decode(value) + pathlib.Path(os.environ['RUNNER_TEMP'], 'tarp-signing.p12').write_bytes(data) + PYTHON + TARP_KEYCHAIN_PASSWORD=$(openssl rand -hex 24) + TARP_KEYCHAIN="$RUNNER_TEMP/tarp-signing.keychain-db" + security create-keychain -p "$TARP_KEYCHAIN_PASSWORD" "$TARP_KEYCHAIN" + security set-keychain-settings -lut 21600 "$TARP_KEYCHAIN" + security unlock-keychain -p "$TARP_KEYCHAIN_PASSWORD" "$TARP_KEYCHAIN" + security import "$RUNNER_TEMP/tarp-signing.p12" -k "$TARP_KEYCHAIN" -P "$MAC_CSC_KEY_PASSWORD" -T /usr/bin/codesign + security list-keychains -d user -s "$TARP_KEYCHAIN" login.keychain-db + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$TARP_KEYCHAIN_PASSWORD" "$TARP_KEYCHAIN" + - name: 构建安装包 + working-directory: apps/desktop + env: + TARP_CODESIGN: ${{ inputs.sign && secrets.MAC_DEVELOPER_ID != '' && matrix.platform == 'mac' && '1' || '0' }} + TARP_NOTARIZE: ${{ inputs.sign && secrets.MAC_DEVELOPER_ID != '' && secrets.APPLE_ID != '' && matrix.platform == 'mac' && '1' || '0' }} + ELECTROBUN_DEVELOPER_ID: ${{ matrix.platform == 'mac' && secrets.MAC_DEVELOPER_ID || '' }} + ELECTROBUN_APPLEID: ${{ matrix.platform == 'mac' && secrets.APPLE_ID || '' }} + ELECTROBUN_APPLEIDPASS: ${{ matrix.platform == 'mac' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }} + ELECTROBUN_TEAMID: ${{ matrix.platform == 'mac' && secrets.APPLE_TEAM_ID || '' }} + run: bun run dist + - name: 验证打包后的 Bun、CLI、模组、Wasm 和 SQLite + run: bun scripts/release/smoke.ts desktop + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: desktop-${{ matrix.platform }}-${{ matrix.arch }} + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.tar.zst + apps/desktop/release/*.tar.gz + apps/desktop/release/*.json + apps/desktop/release/*.patch + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fcf297..110e1af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,6 @@ name: 仓库检查 on: pull_request: - push: - branches: ["**"] workflow_dispatch: workflow_call: @@ -33,3 +31,9 @@ jobs: - run: bun run typecheck - run: bun run test - run: bun run build + + desktop: + name: 桌面分发验收 + if: github.event_name == 'pull_request' + needs: check + uses: ./.github/workflows/build-desktop.yml diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index f0f8287..133c968 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -16,86 +16,11 @@ jobs: uses: ./.github/workflows/ci.yml build: - name: 构建 ${{ matrix.platform }} ${{ matrix.arch }} needs: check - runs-on: ${{ matrix.os }} - timeout-minutes: 40 - strategy: - fail-fast: false - matrix: - include: - - os: windows-2022 - platform: win - arch: x64 - - os: ubuntu-24.04 - platform: linux - arch: x64 - - os: macos-15 - platform: mac - arch: arm64 - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: ./.github/actions/setup - - name: 校验 tag 与桌面版本 - run: | - bun run - <<'JS' - const { version } = require('./apps/desktop/package.json'); - if (process.env.GITHUB_REF_NAME !== `desktop-v${version}`) { - throw new Error(`tag 必须为 desktop-v${version}`); - } - JS - - name: 导入 macOS 签名证书 - if: runner.os == 'macOS' - env: - MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} - MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} - run: | - if [[ -z "$MAC_CSC_LINK" ]]; then exit 0; fi - python3 - <<'PYTHON' - import base64, os, pathlib, urllib.request - value = os.environ['MAC_CSC_LINK'] - data = urllib.request.urlopen(value).read() if value.startswith('https://') else base64.b64decode(value) - pathlib.Path(os.environ['RUNNER_TEMP'], 'tarp-signing.p12').write_bytes(data) - PYTHON - TARP_KEYCHAIN_PASSWORD=$(openssl rand -hex 24) - TARP_KEYCHAIN="$RUNNER_TEMP/tarp-signing.keychain-db" - security create-keychain -p "$TARP_KEYCHAIN_PASSWORD" "$TARP_KEYCHAIN" - security set-keychain-settings -lut 21600 "$TARP_KEYCHAIN" - security unlock-keychain -p "$TARP_KEYCHAIN_PASSWORD" "$TARP_KEYCHAIN" - security import "$RUNNER_TEMP/tarp-signing.p12" -k "$TARP_KEYCHAIN" -P "$MAC_CSC_KEY_PASSWORD" -T /usr/bin/codesign - security list-keychains -d user -s "$TARP_KEYCHAIN" login.keychain-db - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$TARP_KEYCHAIN_PASSWORD" "$TARP_KEYCHAIN" - - name: 构建安装包 - working-directory: apps/desktop - env: - TARP_CODESIGN: ${{ secrets.MAC_DEVELOPER_ID != '' && matrix.platform == 'mac' && '1' || '0' }} - TARP_NOTARIZE: ${{ secrets.MAC_DEVELOPER_ID != '' && secrets.APPLE_ID != '' && matrix.platform == 'mac' && '1' || '0' }} - ELECTROBUN_DEVELOPER_ID: ${{ matrix.platform == 'mac' && secrets.MAC_DEVELOPER_ID || '' }} - ELECTROBUN_APPLEID: ${{ matrix.platform == 'mac' && secrets.APPLE_ID || '' }} - ELECTROBUN_APPLEIDPASS: ${{ matrix.platform == 'mac' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }} - ELECTROBUN_TEAMID: ${{ matrix.platform == 'mac' && secrets.APPLE_TEAM_ID || '' }} - run: bun run dist - - name: 验证打包后的 Bun、CLI、模组、Wasm 和 SQLite - run: bun scripts/release/smoke.ts desktop - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: | - apps/desktop/release/*.exe - apps/desktop/release/*.tar.zst - apps/desktop/release/*.tar.gz - apps/desktop/release/*.json - apps/desktop/release/*.patch - apps/desktop/release/*.dmg - apps/desktop/release/*.zip - if-no-files-found: error - compression-level: 0 - retention-days: 14 + uses: ./.github/workflows/build-desktop.yml + with: + sign: true + secrets: inherit release: name: 汇总安装包并创建 Release 草稿 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6b0f34c..b34618e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -60,7 +60,7 @@ Git 钩子由 monorepo 根目录的 [simple-git-hooks](https://github.com/toplen GitHub Actions 的版本 tag、跨平台构建、签名配置和 Release 草稿验收见[发布流程](../../docs/发布流程.md)。 -应用图标的 1024 × 1024 完整画布主图保存在 `build/tarp-icon-source.png`,不含圆角裁切;`build/icon.png` 供 Windows、Linux 窗口与打包使用。macOS 使用已定稿的 `build/icon-mac.png`:1024 × 1024 透明画布,832 × 832 底板,四边各留白 96 像素。素材已包含圆角与透明留白,直接使用,不再缩放或补边。`build.mac.icons` 指向生成的 `out/icon.iconset`,配置与转换逻辑分别位于 `electrobun.config.ts` 和 `scripts/desktop.ts`。 +应用图标的 1024 × 1024 完整画布主图保存在 `build/tarp-icon-source.png`,不含圆角裁切;`build/icon.png` 供 Linux 窗口与打包使用。Windows 使用由它缩小得到的 `build/icon-win.png`(256 × 256);Hutch 的 Windows 打包器只接受边长不超过 256 像素的正方形 PNG。更新 `build/icon.png` 时同步生成 Windows 图标。macOS 使用已定稿的 `build/icon-mac.png`:1024 × 1024 透明画布,832 × 832 底板,四边各留白 96 像素。素材已包含圆角与透明留白,直接使用,不再缩放或补边。`build.mac.icons` 指向生成的 `out/icon.iconset`,配置与转换逻辑分别位于 `electrobun.config.ts` 和 `scripts/desktop.ts`。 更新 macOS 图标时直接替换 `build/icon-mac.png`,保留定稿文件自身的圆角和透明留白。开发环境重启应用后生效,发布包需要重新打包。界面中的横版 TARP 字标继续使用原 SVG。 diff --git a/apps/desktop/build/icon-win.png b/apps/desktop/build/icon-win.png new file mode 100644 index 0000000..02f3998 Binary files /dev/null and b/apps/desktop/build/icon-win.png differ diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts index 0306929..1b2911a 100644 --- a/apps/desktop/electrobun.config.ts +++ b/apps/desktop/electrobun.config.ts @@ -34,7 +34,7 @@ export default { codesign: process.env.TARP_CODESIGN === "1", notarize: process.env.TARP_NOTARIZE === "1", }, - win: { bundleCEF: false, icon: "build/icon.png" }, + win: { bundleCEF: false, icon: "build/icon-win.png" }, linux: { bundleCEF: false, icon: "build/icon.png" }, }, runtime: { exitOnLastWindowClosed: false }, diff --git a/apps/desktop/scripts/desktop.ts b/apps/desktop/scripts/desktop.ts index f95382f..5728214 100644 --- a/apps/desktop/scripts/desktop.ts +++ b/apps/desktop/scripts/desktop.ts @@ -15,7 +15,7 @@ async function run(args: string[], env = process.env) { stdin: "inherit", }); const code = await child.exited; - if (code) throw new Error(`桌面命令执行失败(${code}):${args.slice(0, 4).join(" ")}`); + if (code) throw new Error(`桌面命令执行失败(${code}):${args.join(" ")}`); } await run(["bun", "run", "prepare:desktop"]); diff --git "a/docs/\345\217\221\345\270\203\346\265\201\347\250\213.md" "b/docs/\345\217\221\345\270\203\346\265\201\347\250\213.md" index 07ab310..25c1a53 100644 --- "a/docs/\345\217\221\345\270\203\346\265\201\347\250\213.md" +++ "b/docs/\345\217\221\345\270\203\346\265\201\347\250\213.md" @@ -2,20 +2,24 @@ ## 工作流 -| 入口 | 触发条件 | 行为 | -| --------------------------------------- | ---------------------- | -------------------------------------- | -| `.github/workflows/ci.yml` | PR、分支提交、手动运行 | 格式、类型、完整测试与构建 | -| `.github/workflows/release-desktop.yml` | `desktop-v*` tag | 检查 → 各平台打包与验收 → Release 草稿 | -| `.github/workflows/release-cli.yml` | `cli-v*` tag | 检查 → CLI 打包与验收 → Release 草稿 | +| 入口 | 触发条件 | 行为 | +| --------------------------------------- | -------------------------- | ----------------------------------------------- | +| `.github/workflows/ci.yml` | PR、手动运行、发布流程调用 | 格式、类型、完整测试与构建;PR 另验收桌面安装包 | +| `.github/workflows/release-desktop.yml` | `desktop-v*` tag | 检查 → 各平台打包与验收 → Release 草稿 | +| `.github/workflows/release-cli.yml` | `cli-v*` tag | 检查 → CLI 打包与验收 → Release 草稿 | 发布工作流复用 CI,共用 `.github/actions/setup/action.yml` 安装根级 `packageManager` 指定的 Bun。依赖统一从根目录按 `bun.lock` 安装;共享包随产品构建,官网和剧情模组不参与构建。CLI 兼容性检查与 CLI 发布流程另外安装 Node.js 22.12.0,验证支持的最低版本。第三方 Actions 固定到提交 SHA,升级时同步核对版本。 -日常 CI 只执行仓库检查;分发包验收放在对应发布流程。`scripts/release/smoke.ts` 使用独立临时模组与配置,验证分发产物的版本、帮助、模组加载、原生公式计算和退出码。CLI 从 tgz 独立安装后分别使用 Node.js 和 Bun 运行,使用符合类型目录结构的测试资料覆盖 JSON、TOML 与 Markdown 解析,并检查已安装运行依赖的许可文件;桌面则运行各平台打包后的应用,并验证包内 SQLite 驱动与 Kysely 的加载、写入、事务回滚和重开恢复。 +开发环境安装步骤缓存 Bun 的依赖下载目录,以及 Hutch 下的 `npm/`、`releases/`、`toolchains/`;按操作系统、架构、锁文件与工具链配置区分缓存。命中后仍执行 `bun install --frozen-lockfile`、构建和验收,不缓存 `node_modules`、项目构建目录、安装包或签名凭据。缓存不可用时按正常流程重新下载。 + +GitHub 缓存有分支和 tag 作用域限制,PR 保存的缓存不能由合并后的发布 tag 直接读取;首次发布或新的 tag 可能需要重新下载,同一 tag 重跑可复用已有缓存。默认分支已有的兼容缓存也可供其他分支及发布流程读取。 + +普通分支 push 不触发检查;创建或更新 PR 时执行仓库检查,通过后调用 `.github/workflows/build-desktop.yml`,构建并验收 Windows x64、Linux x64 和 macOS ARM64 安装包。PR 不传入签名凭据、不创建 Release;安装包作为 Actions 产物保留 14 天。桌面发布复用同一构建与验收流程,并按配置签名、公证;手动运行仓库检查只执行格式、类型、测试与构建。CLI 分发包验收仍在 CLI 发布流程执行。`scripts/release/smoke.ts` 使用独立临时模组与配置,验证分发产物的版本、帮助、模组加载、原生公式计算和退出码。CLI 从 tgz 独立安装后分别使用 Node.js 和 Bun 运行,使用符合类型目录结构的测试资料覆盖 JSON、TOML 与 Markdown 解析,并检查已安装运行依赖的许可文件;桌面则运行各平台打包后的应用,并验证包内 SQLite 驱动与 Kysely 的加载、写入、事务回滚和重开恢复。 ## 发布版本 1. 修改目标产品的 `package.json` 版本:桌面为 `apps/desktop/`,CLI 为 `packages/module-cli/`。 -2. 从根目录运行 `bun install` 更新根级锁文件,提交并合入默认分支,确认 CI 通过。影响公开行为或模组格式时同步修改官网正文。 +2. 从根目录运行 `bun install` 更新根级锁文件,提交 PR,确认检查和桌面分发验收通过后合入默认分支。影响公开行为或模组格式时同步修改官网正文。 3. 从该提交创建并推送对应 tag,版本必须与目标包完全一致: ```sh diff --git a/packages/core/src/node/storage/file-queue.ts b/packages/core/src/node/storage/file-queue.ts index 2d7310a..8e082cb 100644 --- a/packages/core/src/node/storage/file-queue.ts +++ b/packages/core/src/node/storage/file-queue.ts @@ -1,4 +1,4 @@ -import { realpath } from "node:fs/promises"; +import { readlink, realpath } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; const queues = new Map>(); @@ -9,17 +9,31 @@ export function isMissingFile(error: unknown): boolean { } /** 尚未创建的文件也按真实父目录归一化,避免目录链接绕过同文件队列。 */ -async function queueKey(path: string): Promise { +async function directoryKey(path: string): Promise { try { return await realpath(path); } catch (error) { if (!isMissingFile(error)) throw error; const parent = dirname(path); if (parent === path) throw error; - return join(await queueKey(parent), basename(path)); + return join(await directoryKey(parent), basename(path)); } } +async function queueKey(path: string, links = 0): Promise { + // Bun 在 Linux 上通过文件描述符解析 realpath;原子替换期间旧 inode + // 可能返回带「 (deleted)」的路径,不能用会被替换的文件本身作为队列身份。 + let target: string; + try { + target = await readlink(path); + } catch (error) { + if (!isMissingFile(error) && (error as NodeJS.ErrnoException).code !== "EINVAL") throw error; + return join(await directoryKey(dirname(path)), basename(path)); + } + if (links >= 40) throw Object.assign(new Error("文件链接层级过多"), { code: "ELOOP" }); + return queueKey(resolve(dirname(path), target), links + 1); +} + /** 保护当前进程内的一次完整文件操作;不同文件并行,失败不阻塞后续操作。 */ export async function withFileMutation(path: string, operation: () => Promise): Promise { // 注册顺序固定,避免异步 realpath 完成顺序改变调用顺序。 diff --git a/packages/core/src/node/storage/structured-file.test.ts b/packages/core/src/node/storage/structured-file.test.ts index 0484831..c03eb75 100644 --- a/packages/core/src/node/storage/structured-file.test.ts +++ b/packages/core/src/node/storage/structured-file.test.ts @@ -1,5 +1,6 @@ -import { it, onTestFinished } from "bun:test"; +import { it, onTestFinished, spyOn } from "bun:test"; import { strict as assert } from "node:assert"; +import * as fs from "node:fs/promises"; import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -46,6 +47,30 @@ it("多个存储实例对同文件完整读改写,保留全部增量和未知 assert.deepEqual(await store(path).read(), { count: 12, extra: "保留" }); }); +it("原子替换期间旧文件的 realpath 结果变化不会拆散同文件更新队列", async () => { + const path = join(await directory(), "config.json"); + await store(path).write({ count: 0, extra: "保留" }); + const realpath = fs.realpath; + let resolutions = 0; + const resolving = spyOn(fs, "realpath").mockImplementation((async ( + target: Parameters[0], + ) => { + const actual = await realpath(target); + // 模拟 Linux 在旧文件已被 rename 替换后返回的描述符路径。 + return target === path && ++resolutions % 2 === 0 ? `${actual} (deleted)` : actual; + }) as typeof realpath); + onTestFinished(() => resolving.mockRestore()); + await Promise.all( + Array.from({ length: 12 }, () => + store(path).update(async (value) => { + await Promise.resolve(); + return { ...value, count: value.count + 1 }; + }), + ), + ); + assert.deepEqual(await store(path).read(), { count: 12, extra: "保留" }); +}); + it("读取等待先前写入,写入使用调用时的快照", async () => { const path = join(await directory(), "config.json"); const value = { count: 1, nested: { label: "原值" } };