From 360988de5ebb141287e379228255e90524ab6ab8 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 2 Jul 2026 09:41:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=A8=B3=E5=AE=9A=E5=8C=96=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/desktop-release-candidate.yml | 134 +++++++++++++++ ...desktop-release-stabilization-checklist.md | 24 ++- docs/desktop-project-plan.md | 22 +++ docs/guides/branch-maintenance-sop-zh.md | 4 +- docs/guides/client-release.md | 17 +- frontend/package.json | 3 + .../scripts/create-desktop-update-feed.mjs | 156 ++++++++++++++++++ .../verify-desktop-release-readiness.mjs | 101 ++++++++++++ frontend/scripts/verify-desktop-release.mjs | 31 ++++ .../scripts/verify-desktop-update-feed.mjs | 65 +++++++- frontend/scripts/verify-release-build-env.mjs | 6 + 11 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/desktop-release-candidate.yml create mode 100644 frontend/scripts/create-desktop-update-feed.mjs create mode 100644 frontend/scripts/verify-desktop-release-readiness.mjs diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml new file mode 100644 index 00000000..2220b6d0 --- /dev/null +++ b/.github/workflows/desktop-release-candidate.yml @@ -0,0 +1,134 @@ +name: Desktop Release Candidate + +on: + workflow_dispatch: + inputs: + artifact_base_url: + description: "Versioned HTTPS prefix for immutable desktop artifacts, for example https://ctms.example.com/desktop-updates/stable/v0.1.0/" + required: true + type: string + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + macos-release-candidate: + name: Signed macOS release candidate + runs-on: macos-latest + defaults: + run: + working-directory: frontend + env: + VITE_BUILD_CHANNEL: release + VITE_BUILD_COMMIT: ${{ github.sha }} + RELEASE_BUILD: "true" + REQUIRE_DESKTOP_SIGNING: "true" + DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + steps: + - name: Checkout release source + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Enforce release tag context + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then + echo "Signed desktop release candidates must run from a vX.Y.Z tag." + exit 1 + fi + expected_tag="v$(node -p "require('./package.json').version")" + if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then + echo "Release tag ${GITHUB_REF_NAME} does not match package version ${expected_tag}." + exit 1 + fi + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + + - name: Install dependencies + run: npm ci + + - name: Check release build metadata and signing environment + run: npm run release:env:check + + - name: Check signed desktop release readiness + run: npm run desktop:release-readiness:check + + - name: Check synchronized client version + run: npm run version:check + + - name: Check runtime boundary + run: npm run runtime:check + + - name: Check desktop release and security gate + run: npm run desktop:release:check + + - name: Check UI contract + run: npm run ui:contract + + - name: Type check + run: npm run type-check + + - name: Unit tests + run: npm run test:unit + + - name: Build Web artifact + run: npm run build + + - name: Build signed Universal macOS artifacts + run: npm run desktop:build:macos-release -- --ci + + - name: Create desktop update feed + shell: bash + run: | + if [[ -z "${DESKTOP_UPDATE_BASE_URL}" ]]; then + echo "DESKTOP_UPDATE_BASE_URL or workflow input artifact_base_url is required." + exit 1 + fi + + artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)" + if [[ -z "${artifact}" ]]; then + echo "No macOS updater artifact was produced." + exit 1 + fi + + include_args=() + dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)" + if [[ -n "${dmg}" ]]; then + include_args+=(--include "${dmg}") + fi + + npm run desktop:update-feed:create -- \ + --artifact "${artifact}" \ + "${include_args[@]}" \ + --output-dir src-tauri/target/desktop-release-feed \ + --base-url "${DESKTOP_UPDATE_BASE_URL}" + + - name: Verify desktop update feed + run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}" + + - name: Upload verified desktop release directory + uses: actions/upload-artifact@v4 + with: + name: ctms-desktop-release-${{ github.ref_name }} + path: frontend/src-tauri/target/desktop-release-feed/* + if-no-files-found: error diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index f5a495eb..56efd37b 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -28,10 +28,12 @@ npm run desktop:build:app - [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。 - [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=` 由 CI 注入,且 `npm run release:env:check` 通过。 +- [ ] 在正式 release tag 和签名环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址齐备。 - [ ] macOS app 已签名和公证。 - [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。 -- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build -- --bundles app`。 -- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed --artifacts-dir `。 +- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build:macos-release -- --ci`。 +- [ ] 正式 updater feed 先执行 `npm run desktop:update-feed:create -- --artifact --base-url --output-dir ` 生成 `latest.json` 与 `SHA256SUMS.txt`。 +- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir `,并确认 checksum manifest、updater artifact、`.sig` 和 `latest.json` 均通过校验。 - [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。 - [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。 @@ -50,6 +52,7 @@ npm run desktop:build:app - [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。 - [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。 - [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。 +- [ ] signed macOS release candidate workflow 只允许从 `vX.Y.Z` tag 运行,并包含签名环境检查、Universal macOS 构建、update feed 生成、checksum 校验和 verified release directory 上传。 人工复审还必须确认: @@ -127,3 +130,20 @@ npm run desktop:build:app - 签名后的 updater artifacts、`.sig`、checksum manifest 和 `latest.json` 在真实发布目录内通过 `npm run desktop:update-feed:check`。 - 不可变制品上传完成后,再原子替换线上 `latest.json`。 - Desktop 端到端人工回归矩阵、最小窗口体验验收和系统通知/自动更新真实环境验证。 + +## 7. 2026-07-02 发布稳定化推进记录 + +本次推进补齐了发布链路自动化,不改变桌面端产品边界: + +- 新增 `npm run desktop:build:macos-release`,封装 Universal macOS `app`/`dmg` release candidate 构建命令。 +- 新增 `npm run desktop:update-feed:create`,从签名 updater artifact 和 `.sig` 生成 `latest.json`、复制发布目录文件并生成 `SHA256SUMS.txt`。 +- `npm run desktop:update-feed:check` 在传入 `--artifacts-dir` 时要求并校验 `SHA256SUMS.txt`。 +- 新增 `npm run desktop:release-readiness:check`,在正式签名候选构建前检查 release tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址。 +- 新增 `.github/workflows/desktop-release-candidate.yml`,在 release tag 上执行签名候选构建、feed 生成、feed 校验并上传 verified release directory。 +- `npm run desktop:release:check` 已检查上述脚本和 workflow,避免发布链路回退。 + +仍未自动完成、正式发布前必须人工确认: + +- Apple Developer 凭据、证书、签名身份、公证结果和组织 updater 私钥。 +- 生产下载源的不可变制品上传和线上 `latest.json` 原子替换。 +- 真实环境下的自动更新安装、系统通知、单实例和完整人工回归。 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md index 7c45a7bf..8dcd39c7 100644 --- a/docs/desktop-project-plan.md +++ b/docs/desktop-project-plan.md @@ -110,6 +110,28 @@ 5. CI 与发布流程:Web 与 Desktop 必须从同一提交、同一语义化版本号和同一正式标签构建;发布候选应执行本文档列出的相关质量门禁。 6. Windows 兼容验证:仅作为第二阶段兼容性目标,验证 Credential Manager、路径处理、通知/updater 编译、WebView2 和安装器假设;未获明确批准前不发布正式 Windows 安装包。 +## 2026-07-02 发布稳定化推进记录 + +本次推进仍保持第一、二阶段边界,不引入离线、本地业务存储、内嵌后端或独立桌面业务 UI。 + +已补齐的发布稳定化自动化: + +- 新增 `npm run desktop:build:macos-release`,用于 macOS Universal `app`/`dmg` 正式候选构建。 +- 新增 `npm run desktop:update-feed:create`,从签名 updater artifact 和 `.sig` 生成 `latest.json` 与 `SHA256SUMS.txt`,并要求 artifact URL 使用包含当前版本号的 HTTPS 不可变路径。 +- `npm run desktop:update-feed:check` 在传入 `--artifacts-dir` 时同步校验 `SHA256SUMS.txt`、updater artifact、`.sig` 和 `latest.json`。 +- 新增 `npm run desktop:release-readiness:check`,用于在进入正式签名候选构建前确认当前提交精确匹配 `vX.Y.Z` tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址已经齐备。 +- 新增 `.github/workflows/desktop-release-candidate.yml`,在 release tag 上执行签名 macOS 候选构建、feed 生成、feed 校验和 GitHub artifact 上传;它不替代人工发布审批和生产下载源原子替换。 +- `npm run desktop:release:check` 已纳入上述发布候选工作流和脚本存在性检查,防止发布链路门禁被误删。 + +仍需正式发布负责人在真实发布环境完成: + +- Apple Developer 签名、公证凭据和组织 updater 私钥配置。 +- 从正式 `vX.Y.Z` tag 运行 signed macOS release candidate workflow。 +- 将已校验的不可变制品上传到生产下载源,最后原子替换线上 `latest.json`。 +- 执行桌面端人工端到端回归、最小窗口体验验收和真实系统通知/自动更新验证。 + +进入端到端人工回归前,应先确认 `npm run desktop:release-readiness:check` 在正式 release tag 和签名环境中通过;否则只能进行普通 smoke 验证,不能判定 macOS 发布稳定化已经完成。 + 如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。 ## 当前质量门禁 diff --git a/docs/guides/branch-maintenance-sop-zh.md b/docs/guides/branch-maintenance-sop-zh.md index fccf2236..9151fa12 100644 --- a/docs/guides/branch-maintenance-sop-zh.md +++ b/docs/guides/branch-maintenance-sop-zh.md @@ -106,8 +106,8 @@ npm run desktop:build:app npm run desktop:build:app ``` -正式桌面发布构建仍必须设置 updater 签名私钥后执行 -`npm run desktop:build -- --bundles app`。 +正式桌面发布构建仍必须设置 updater 签名私钥和 Apple 签名/公证变量后,先执行 +`npm run desktop:release-readiness:check`,再执行 `npm run desktop:build:macos-release -- --ci`。 后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。 diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md index 001e8987..bb7de085 100644 --- a/docs/guides/client-release.md +++ b/docs/guides/client-release.md @@ -122,8 +122,8 @@ The release pipeline must: 2. build macOS Universal desktop artifacts; 3. sign and notarize the macOS app; 4. produce updater artifacts and `.sig` files with the updater private key; -5. generate `latest.json` and a checksum manifest; -6. verify the feed with `npm run desktop:update-feed:check -- --feed --artifacts-dir `; +5. generate `latest.json` and a checksum manifest with `npm run desktop:update-feed:create`; +6. verify the feed with `npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir `; 7. upload immutable artifacts first; 8. atomically replace `latest.json` last. @@ -131,6 +131,13 @@ For Universal macOS artifacts, `latest.json` must provide both `darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal update package. +The signed release candidate workflow lives at +`.github/workflows/desktop-release-candidate.yml`. It must be run from a +matching `vX.Y.Z` tag and produces a verified release directory as a GitHub +artifact. That artifact is still only a release candidate; the release owner +must upload immutable files to the production download origin and replace +`latest.json` atomically after validation. + ## Windows Build Readiness Second-phase Windows work is limited to CI compatibility validation. A @@ -167,8 +174,10 @@ export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY" export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD" export REQUIRE_DESKTOP_SIGNING=true npm run release:env:check -npm run desktop:build -- --bundles app -npm run desktop:update-feed:check -- --feed src-tauri/target/release/bundle/latest.json --artifacts-dir src-tauri/target/release/bundle +npm run desktop:release-readiness:check +npm run desktop:build:macos-release -- --ci +npm run desktop:update-feed:create -- --artifact --base-url --output-dir +npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir ``` The Desktop build must run on macOS for the current first-phase target. A signed diff --git a/frontend/package.json b/frontend/package.json index d5102bc0..ef7237ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,11 @@ "desktop:dev": "tauri dev", "desktop:build": "tauri build", "desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app", + "desktop:build:macos-release": "tauri build --target universal-apple-darwin --bundles app,dmg", + "desktop:update-feed:create": "node scripts/create-desktop-update-feed.mjs", "desktop:bundle:dmg": "tauri build --bundles dmg", "desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs", + "desktop:release-readiness:check": "node scripts/verify-desktop-release-readiness.mjs", "release:env:check": "node scripts/verify-release-build-env.mjs", "version:check": "node scripts/client-version.mjs --check", "version:set": "node scripts/client-version.mjs --set", diff --git a/frontend/scripts/create-desktop-update-feed.mjs b/frontend/scripts/create-desktop-update-feed.mjs new file mode 100644 index 00000000..119b872b --- /dev/null +++ b/frontend/scripts/create-desktop-update-feed.mjs @@ -0,0 +1,156 @@ +import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const failures = []; + +const args = process.argv.slice(2); +const values = (name) => { + const found = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === name && args[index + 1]) { + found.push(args[index + 1]); + index += 1; + } + } + return found; +}; +const value = (name) => values(name)[0]; + +const fail = (message) => failures.push(message); +const assert = (condition, message) => { + if (!condition) fail(message); +}; + +const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT; +const outputDir = resolve( + frontendDir, + value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed", +); +const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL; +const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString(); +const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES; +const includes = values("--include").map((path) => resolve(frontendDir, path)); + +assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required."); +assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required."); + +const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined; +const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined; +const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined; +const uploadFiles = []; + +const readRequiredFile = async (path, description) => { + try { + return await readFile(path); + } catch (error) { + fail(`${description} cannot be read: ${path} (${error.message})`); + return undefined; + } +}; + +const sha256 = async (path) => { + const data = await readFile(path); + return createHash("sha256").update(data).digest("hex"); +}; + +const normalizedBaseUrl = () => { + if (!baseUrlRaw) return undefined; + try { + const url = new URL(baseUrlRaw.endsWith("/") ? baseUrlRaw : `${baseUrlRaw}/`); + assert(url.protocol === "https:", "Desktop update artifact base URL must use HTTPS."); + assert(url.username === "" && url.password === "", "Desktop update artifact base URL must not include credentials."); + assert(!/[?&]token=/i.test(url.search), "Desktop update artifact base URL must not include token query parameters."); + assert(url.pathname.includes(packageInfo.version), `Desktop update artifact base URL must include version ${packageInfo.version}.`); + return url; + } catch (error) { + fail(`Desktop update artifact base URL is invalid: ${baseUrlRaw} (${error.message})`); + return undefined; + } +}; + +const copyIntoOutput = async (sourcePath) => { + const destination = resolve(outputDir, basename(sourcePath)); + if (sourcePath !== destination) { + await copyFile(sourcePath, destination); + } + return destination; +}; + +if (resolvedArtifactPath && signaturePath) { + await readRequiredFile(resolvedArtifactPath, "Updater artifact"); + const signature = await readRequiredFile(signaturePath, "Updater artifact signature"); + if (signature) { + const signatureText = signature.toString("utf8").trim(); + assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short."); + } +} + +for (const includePath of includes) { + try { + const metadata = await stat(includePath); + assert(metadata.isFile(), `Included release file must be a file: ${includePath}`); + } catch (error) { + fail(`Included release file cannot be read: ${includePath} (${error.message})`); + } +} + +const baseUrl = normalizedBaseUrl(); + +if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) { + await mkdir(outputDir, { recursive: true }); + + const artifactUrl = new URL(artifactName, baseUrl).toString(); + assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json."); + assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters."); + + const signature = (await readFile(signaturePath, "utf8")).trim(); + const latest = { + version: packageInfo.version, + pub_date: pubDate, + platforms: { + "darwin-aarch64": { + signature, + url: artifactUrl, + }, + "darwin-x86_64": { + signature, + url: artifactUrl, + }, + }, + }; + + if (notes) { + latest.notes = notes; + } + + uploadFiles.push(await copyIntoOutput(resolvedArtifactPath)); + uploadFiles.push(await copyIntoOutput(signaturePath)); + for (const includePath of includes) { + uploadFiles.push(await copyIntoOutput(includePath)); + } + + const latestPath = resolve(outputDir, "latest.json"); + await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`); + uploadFiles.push(latestPath); + + const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()]; + const checksumLines = []; + for (const filePath of uniqueFiles) { + checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`); + } + const checksumPath = resolve(outputDir, "SHA256SUMS.txt"); + await writeFile(checksumPath, `${checksumLines.join("\n")}\n`); + + console.log(`Desktop update feed created in ${outputDir}`); + console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`); + console.log(` - SHA256SUMS.txt`); +} + +if (failures.length > 0) { + console.error(`Desktop update feed creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); + process.exitCode = 1; +} diff --git a/frontend/scripts/verify-desktop-release-readiness.mjs b/frontend/scripts/verify-desktop-release-readiness.mjs new file mode 100644 index 00000000..6bd46edf --- /dev/null +++ b/frontend/scripts/verify-desktop-release-readiness.mjs @@ -0,0 +1,101 @@ +import { execFileSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const rootDir = resolve(frontendDir, ".."); +const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const failures = []; + +const env = process.env; +const fullShaPattern = /^[0-9a-f]{40}$/i; +const expectedTag = `v${packageInfo.version}`; +const requiredSecretLikeEnv = [ + "TAURI_SIGNING_PRIVATE_KEY", + "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", + "APPLE_ID", + "APPLE_PASSWORD", + "APPLE_TEAM_ID", +]; + +const fail = (message) => failures.push(message); +const assert = (condition, message) => { + if (!condition) fail(message); +}; + +const git = (args) => + execFileSync("git", args, { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + +const gitMaybe = (args) => { + try { + return git(args); + } catch { + return undefined; + } +}; + +const requireEnv = (name) => { + assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`); +}; + +const validateBaseUrl = () => { + const raw = env.DESKTOP_UPDATE_BASE_URL; + requireEnv("DESKTOP_UPDATE_BASE_URL"); + if (!raw) return; + + let url; + try { + url = new URL(raw.endsWith("/") ? raw : `${raw}/`); + } catch (error) { + fail(`DESKTOP_UPDATE_BASE_URL is invalid: ${error.message}`); + return; + } + + assert(url.protocol === "https:", "DESKTOP_UPDATE_BASE_URL must use HTTPS."); + assert(url.username === "" && url.password === "", "DESKTOP_UPDATE_BASE_URL must not include credentials."); + assert(!/[?&]token=/i.test(url.search), "DESKTOP_UPDATE_BASE_URL must not include token query parameters."); + assert( + url.pathname.includes(packageInfo.version), + `DESKTOP_UPDATE_BASE_URL must include the immutable version segment ${packageInfo.version}.`, + ); +}; + +const headSha = gitMaybe(["rev-parse", "HEAD"]); +const exactTag = gitMaybe(["describe", "--tags", "--exact-match", "HEAD"]); +const status = gitMaybe(["status", "--porcelain"]); + +assert(Boolean(headSha), "Current Git commit cannot be resolved."); +assert(exactTag === expectedTag, `Current commit must be exactly tagged ${expectedTag}; found ${exactTag || ""}.`); +assert(status === "", "Release readiness requires a clean working tree."); + +assert(env.VITE_BUILD_CHANNEL === "release", "VITE_BUILD_CHANNEL must be release."); +assert(fullShaPattern.test(env.VITE_BUILD_COMMIT || ""), "VITE_BUILD_COMMIT must be the full release commit SHA."); +if (headSha && env.VITE_BUILD_COMMIT) { + assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit."); +} + +for (const name of requiredSecretLikeEnv) { + requireEnv(name); +} + +assert( + Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY), + "APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.", +); +if (env.APPLE_CERTIFICATE) { + requireEnv("APPLE_CERTIFICATE_PASSWORD"); +} + +validateBaseUrl(); + +if (failures.length > 0) { + console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); + process.exitCode = 1; +} else { + console.log("Desktop release readiness check passed."); +} diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index 6b790a3b..09c811b3 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -182,6 +182,19 @@ const verifyUpdaterBoundary = async () => { }; const verifyWorkflowGates = async () => { + const packageInfo = await readJson(resolve(frontendDir, "package.json")); + const requiredScripts = [ + "desktop:build:macos-release", + "desktop:update-feed:create", + "desktop:update-feed:check", + "desktop:release-readiness:check", + "release:env:check", + ]; + + for (const script of requiredScripts) { + assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`); + } + const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8"); const requiredCommands = [ "npm run version:check", @@ -200,6 +213,24 @@ const verifyWorkflowGates = async () => { } assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL."); assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT."); + + const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8"); + const requiredReleaseWorkflowTokens = [ + "REQUIRE_DESKTOP_SIGNING", + "TAURI_SIGNING_PRIVATE_KEY", + "APPLE_ID", + "APPLE_PASSWORD", + "APPLE_TEAM_ID", + "npm run desktop:build:macos-release", + "npm run desktop:update-feed:create", + "npm run desktop:update-feed:check", + "npm run desktop:release-readiness:check", + "actions/upload-artifact", + ]; + + for (const token of requiredReleaseWorkflowTokens) { + assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`); + } }; await verifyTauriConfig(); diff --git a/frontend/scripts/verify-desktop-update-feed.mjs b/frontend/scripts/verify-desktop-update-feed.mjs index cb18e00c..eeed8a64 100644 --- a/frontend/scripts/verify-desktop-update-feed.mjs +++ b/frontend/scripts/verify-desktop-update-feed.mjs @@ -1,4 +1,5 @@ import { access, readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,6 +19,10 @@ const feedPath = resolve( ); const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR; const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL; +const checksumManifestPath = + optionValue("--checksum-manifest") || + process.env.DESKTOP_UPDATE_CHECKSUM_MANIFEST || + (artifactDir ? resolve(artifactDir, "SHA256SUMS.txt") : undefined); const fail = (message) => failures.push(message); const assert = (condition, message) => { @@ -32,6 +37,55 @@ const assertFileExists = async (path, description) => { } }; +const sha256 = async (path) => createHash("sha256").update(await readFile(path)).digest("hex"); + +const readChecksumManifest = async () => { + if (!checksumManifestPath) return undefined; + try { + const source = await readFile(checksumManifestPath, "utf8"); + const checksums = new Map(); + for (const line of source.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i); + if (!match) { + fail(`Checksum manifest contains an invalid line: ${line}`); + continue; + } + checksums.set(basename(match[2]), match[1].toLowerCase()); + } + return checksums; + } catch (error) { + fail(`Cannot read checksum manifest ${checksumManifestPath}: ${error.message}`); + return undefined; + } +}; + +const assertChecksum = async (checksums, path, description) => { + if (!checksums) return; + const name = basename(path); + const expected = checksums.get(name); + assert(Boolean(expected), `Checksum manifest must include ${description}: ${name}`); + if (expected) { + const actual = await sha256(path); + assert(actual === expected, `${description} checksum mismatch for ${name}.`); + } +}; + +const assertManifestEntries = async (checksums) => { + if (!checksums || !artifactDir) return; + for (const [name, expected] of checksums.entries()) { + const path = resolve(artifactDir, name); + await assertFileExists(path, `checksum manifest entry ${name}`); + try { + const actual = await sha256(path); + assert(actual === expected, `Checksum manifest entry mismatch for ${name}.`); + } catch (error) { + fail(`Cannot verify checksum manifest entry ${name}: ${error.message}`); + } + } +}; + let feed; try { feed = JSON.parse(await readFile(feedPath, "utf8")); @@ -40,6 +94,8 @@ try { } if (feed) { + const checksums = await readChecksumManifest(); + await assertManifestEntries(checksums); const normalizedFeedVersion = String(feed.version || "").replace(/^v/, ""); const platforms = feed.platforms || {}; const darwinArm = platforms["darwin-aarch64"]; @@ -80,14 +136,21 @@ if (feed) { if (artifactDir) { const artifactPath = resolve(artifactDir, basename(url.pathname)); + const signaturePath = `${artifactPath}.sig`; await assertFileExists(artifactPath, `${platform} updater artifact`); - await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`); + await assertFileExists(signaturePath, `${platform} updater artifact signature`); + await assertChecksum(checksums, artifactPath, `${platform} updater artifact`); + await assertChecksum(checksums, signaturePath, `${platform} updater artifact signature`); } } if (darwinArm?.url && darwinIntel?.url) { assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact."); } + + if (artifactDir) { + await assertChecksum(checksums, feedPath, "latest.json"); + } } if (failures.length > 0) { diff --git a/frontend/scripts/verify-release-build-env.mjs b/frontend/scripts/verify-release-build-env.mjs index db684972..0e160b10 100644 --- a/frontend/scripts/verify-release-build-env.mjs +++ b/frontend/scripts/verify-release-build-env.mjs @@ -47,6 +47,9 @@ if (isTagBuild) { if (env.REQUIRE_DESKTOP_SIGNING === "true") { assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS."); + if (isCi) { + assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag."); + } requireEnv("TAURI_SIGNING_PRIVATE_KEY"); requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"); requireEnv("APPLE_ID"); @@ -56,6 +59,9 @@ if (env.REQUIRE_DESKTOP_SIGNING === "true") { Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY), "APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.", ); + if (env.APPLE_CERTIFICATE) { + requireEnv("APPLE_CERTIFICATE_PASSWORD"); + } } if (failures.length > 0) {