diff --git a/.github/workflows/client-quality-gates.yml b/.github/workflows/client-quality-gates.yml index a9c7c74c..d03a55c7 100644 --- a/.github/workflows/client-quality-gates.yml +++ b/.github/workflows/client-quality-gates.yml @@ -40,6 +40,8 @@ jobs: web: name: Shared client and Web runs-on: ubuntu-latest + env: + VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }} defaults: run: working-directory: frontend @@ -104,6 +106,8 @@ jobs: desktop: name: macOS Desktop runs-on: macos-latest + env: + VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }} defaults: run: working-directory: frontend diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml index d09b2b45..59c301bb 100644 --- a/.github/workflows/desktop-release-candidate.yml +++ b/.github/workflows/desktop-release-candidate.yml @@ -19,8 +19,35 @@ concurrency: cancel-in-progress: false jobs: + release-policy: + name: Resolve desktop release signing policy + runs-on: ubuntu-latest + outputs: + platform_signing_mode: ${{ steps.policy.outputs.platform_signing_mode }} + macos_signing: ${{ steps.policy.outputs.macos_signing }} + windows_signing: ${{ steps.policy.outputs.windows_signing }} + artifact_label: ${{ steps.policy.outputs.artifact_label }} + warning_required: ${{ steps.policy.outputs.warning_required }} + defaults: + run: + working-directory: frontend + steps: + - name: Checkout release source + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22.13" + + - name: Resolve version-scoped signing policy + id: policy + shell: bash + run: npm run desktop:release-policy:check -- --require-tag --github-output "${GITHUB_OUTPUT}" + macos-release-candidate: - name: Signed macOS release candidate + name: macOS release candidate + needs: release-policy runs-on: macos-latest defaults: run: @@ -28,8 +55,13 @@ jobs: env: VITE_BUILD_CHANNEL: release VITE_BUILD_COMMIT: ${{ github.sha }} + VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }} RELEASE_BUILD: "true" - REQUIRE_DESKTOP_SIGNING: "true" + DESKTOP_RELEASE_PLATFORM: macos + DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }} + REQUIRE_UPDATER_SIGNING: "true" + REQUIRE_DESKTOP_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }} + ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }} 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 }} @@ -54,7 +86,7 @@ jobs: shell: bash run: | if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then - echo "Signed desktop release candidates must run from a vX.Y.Z tag." + echo "Desktop release candidates must run from a vX.Y.Z tag." exit 1 fi expected_tag="v$(node -p "require('./package.json').version")" @@ -77,7 +109,7 @@ jobs: - name: Check release build metadata and signing environment run: npm run release:env:check - - name: Check signed desktop release readiness + - name: Check desktop release readiness run: npm run desktop:release-readiness:check - name: Check synchronized client version @@ -101,9 +133,14 @@ jobs: - name: Build Web artifact run: npm run build - - name: Build signed and notarized Universal macOS artifacts + - name: Build Apple-signed and notarized Universal macOS artifacts + if: needs.release-policy.outputs.platform_signing_mode == 'signed' run: npm run desktop:build:macos-release -- --ci + - name: Build ad-hoc Universal macOS artifacts + if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' + run: npm run desktop:build:macos-unsigned-release -- --ci + - name: Verify and stage macOS artifacts shell: bash run: | @@ -111,28 +148,46 @@ jobs: artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)" dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)" if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then - echo "Signed macOS app, updater artifact/signature, and DMG are all required." + echo "macOS app, updater artifact/signature, and DMG are all required." exit 1 fi codesign --verify --deep --strict --verbose=2 "${app}" - spctl --assess --type execute --verbose=2 "${app}" - xcrun stapler validate "${app}" - xcrun stapler validate "${dmg}" + if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "signed" ]]; then + spctl --assess --type execute --verbose=2 "${app}" + xcrun stapler validate "${app}" + xcrun stapler validate "${dmg}" + else + signature_info="$(codesign -dv --verbose=4 "${app}" 2>&1)" + if ! grep -q "Signature=adhoc" <<<"${signature_info}"; then + echo "The macOS unsigned-platform exception must produce an ad-hoc signed app." + exit 1 + fi + fi stage="src-tauri/target/desktop-release-macos" mkdir -p "${stage}" - cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/" + if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "unsigned-exception" ]]; then + artifact_name="$(basename "${artifact}" .app.tar.gz)_UNSIGNED.app.tar.gz" + dmg_name="$(basename "${dmg}" .dmg)_UNSIGNED.dmg" + cp "${artifact}" "${stage}/${artifact_name}" + cp "${artifact}.sig" "${stage}/${artifact_name}.sig" + cp "${dmg}" "${stage}/${dmg_name}" + cp desktop-release-unsigned-warning.txt "${stage}/UNSIGNED-PLATFORM-RELEASE.txt" + else + cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/" + fi - - name: Upload signed macOS candidate artifacts + - name: Upload macOS candidate artifacts uses: actions/upload-artifact@v4 with: - name: ctms-desktop-macos-${{ github.ref_name }} + name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-macos/* if-no-files-found: error windows-release-candidate: - name: Signed Windows release candidate + name: Windows release candidate + needs: release-policy runs-on: windows-latest defaults: run: @@ -140,8 +195,13 @@ jobs: env: VITE_BUILD_CHANNEL: release VITE_BUILD_COMMIT: ${{ github.sha }} + VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }} RELEASE_BUILD: "true" - REQUIRE_WINDOWS_SIGNING: "true" + DESKTOP_RELEASE_PLATFORM: windows + DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }} + REQUIRE_UPDATER_SIGNING: "true" + REQUIRE_WINDOWS_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }} + ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }} 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 }} @@ -163,7 +223,7 @@ jobs: shell: pwsh run: | if ($env:GITHUB_REF_TYPE -ne "tag") { - throw "Signed desktop release candidates must run from a vX.Y.Z tag." + throw "Desktop release candidates must run from a vX.Y.Z tag." } $expectedTag = "v$(node -p "require('./package.json').version")" if ($env:GITHUB_REF_NAME -ne $expectedTag) { @@ -182,7 +242,7 @@ jobs: - name: Check release build metadata and signing environment run: npm run release:env:check - - name: Check signed desktop release readiness + - name: Check desktop release readiness run: npm run desktop:release-readiness:check - name: Check synchronized client version @@ -207,6 +267,7 @@ jobs: run: npm run build - name: Import Windows code-signing certificate + if: needs.release-policy.outputs.platform_signing_mode == 'signed' shell: pwsh run: | $pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx" @@ -233,6 +294,7 @@ jobs: Remove-Item $pfxPath -Force - name: Write signed Windows Tauri config + if: needs.release-policy.outputs.platform_signing_mode == 'signed' shell: pwsh run: | $config = @{ @@ -249,10 +311,16 @@ jobs: Get-Content "tauri.windows.release.conf.json" - name: Build signed Windows NSIS artifacts + if: needs.release-policy.outputs.platform_signing_mode == 'signed' timeout-minutes: 30 run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci - - name: Verify Authenticode and stage Windows artifacts + - name: Build unsigned Windows NSIS artifacts + if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' + timeout-minutes: 30 + run: npm run desktop:build:windows-release -- --ci + + - name: Verify platform signing mode and stage Windows artifacts shell: pwsh run: | $bundleDir = "src-tauri/target/release/bundle/nsis" @@ -270,14 +338,27 @@ jobs: foreach ($executable in @($appExecutables + $installers)) { $signature = Get-AuthenticodeSignature -FilePath $executable.FullName - if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) { - throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)" + if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "signed") { + if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) { + throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)" + } + } elseif ($signature.Status -ne "NotSigned") { + throw "The Windows unsigned-platform exception must produce an Authenticode-unsigned executable: $($executable.FullName) returned $($signature.Status)." } } $stage = "src-tauri/target/desktop-release-windows" New-Item -ItemType Directory -Path $stage -Force | Out-Null - Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage + if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "unsigned-exception") { + $installerName = $installers[0].Name -replace '\.exe$', '_UNSIGNED.exe' + $updaterName = $updaters[0].Name -replace '\.nsis\.zip$', '_UNSIGNED.nsis.zip' + Copy-Item $installers[0].FullName -Destination (Join-Path $stage $installerName) + Copy-Item $updaters[0].FullName -Destination (Join-Path $stage $updaterName) + Copy-Item $signaturePath -Destination (Join-Path $stage "$updaterName.sig") + Copy-Item "desktop-release-unsigned-warning.txt" -Destination (Join-Path $stage "UNSIGNED-PLATFORM-RELEASE.txt") + } else { + Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage + } - name: Remove Windows signing certificate if: always() @@ -289,16 +370,17 @@ jobs: Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue - - name: Upload signed Windows candidate artifacts + - name: Upload Windows candidate artifacts uses: actions/upload-artifact@v4 with: - name: ctms-desktop-windows-${{ github.ref_name }} + name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-windows/* if-no-files-found: error aggregate-release-candidate: name: Verify combined desktop release directory needs: + - release-policy - macos-release-candidate - windows-release-candidate runs-on: ubuntu-latest @@ -316,18 +398,21 @@ jobs: with: node-version: "22.13" - - name: Download signed macOS candidate artifacts + - name: Download macOS candidate artifacts uses: actions/download-artifact@v4 with: - name: ctms-desktop-macos-${{ github.ref_name }} + name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-input/macos - - name: Download signed Windows candidate artifacts + - name: Download Windows candidate artifacts uses: actions/download-artifact@v4 with: - name: ctms-desktop-windows-${{ github.ref_name }} + name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-input/windows + - name: Create desktop release provenance + run: npm run desktop:release-provenance:create -- --macos-signing "${{ needs.release-policy.outputs.macos_signing }}" --windows-signing "${{ needs.release-policy.outputs.windows_signing }}" --output src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json + - name: Create combined desktop update feed shell: bash run: | @@ -340,20 +425,32 @@ jobs: exit 1 fi + include_args=(--include "${dmgs[0]}" --include "${installers[0]}" --include src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json) + notes_args=() + if [[ "${{ needs.release-policy.outputs.warning_required }}" == "true" ]]; then + mapfile -t warnings < <(find src-tauri/target/desktop-release-input/macos -name 'UNSIGNED-PLATFORM-RELEASE.txt' -type f) + if [[ ${#warnings[@]} -ne 1 ]]; then + echo "The unsigned-platform release warning is required." + exit 1 + fi + include_args+=(--include "${warnings[0]}") + notes_args+=(--notes "UNSIGNED PLATFORM RELEASE: macOS is ad-hoc signed and not notarized; Windows is not Authenticode-signed. Gatekeeper and SmartScreen warnings are expected. Verify tag, commit, updater signatures, and SHA256SUMS.txt before installation.") + fi + npm run desktop:update-feed:create -- \ --artifact "${mac_artifacts[0]}" \ --platform-artifact "windows-x86_64=${windows_artifacts[0]}" \ - --include "${dmgs[0]}" \ - --include "${installers[0]}" \ + "${include_args[@]}" \ + "${notes_args[@]}" \ --output-dir src-tauri/target/desktop-release-feed \ --base-url "${DESKTOP_UPDATE_BASE_URL}" - name: Verify combined 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}" --require-platform windows-x86_64 + 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}" --require-platform windows-x86_64 --require-provenance - name: Upload verified desktop release directory uses: actions/upload-artifact@v4 with: - name: ctms-desktop-release-${{ github.ref_name }} + name: ctms-desktop-release-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} path: frontend/src-tauri/target/desktop-release-feed/* if-no-files-found: error diff --git a/.github/workflows/desktop-windows-internal.yml b/.github/workflows/desktop-windows-internal.yml index 280e639a..6f1c295b 100644 --- a/.github/workflows/desktop-windows-internal.yml +++ b/.github/workflows/desktop-windows-internal.yml @@ -14,6 +14,8 @@ jobs: windows-internal: name: Windows NSIS internal validation runs-on: windows-latest + env: + VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }} defaults: run: working-directory: frontend diff --git a/AGENTS.md b/AGENTS.md index 85b048c9..7dc75e0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,9 @@ - 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。 - 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。 - token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。 -- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建,使用组织 Windows 代码签名证书、RFC 3161 时间戳和 updater 私钥,并通过 Authenticode 与 updater feed 校验。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。 +- 正式桌面客户端的默认 CTMS 服务端入口必须由构建环境变量 `VITE_DESKTOP_SERVER_URL` 注入,不得在运行时代码中写死生产域名;用户仍可在桌面服务器设置中手动覆盖,手动值优先并沿用既有的切换服务器登出与缓存失效边界。该变量只表示业务服务端 origin,不得与 updater 制品前缀 `DESKTOP_UPDATE_BASE_URL` 混用。 +- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建。平台签名默认要求组织 Windows 代码签名证书、RFC 3161 时间戳和 Authenticode 校验;只有 `frontend/desktop-release-policy.json` 中按精确版本记录、经发布负责人批准的例外可跳过平台签名。无论是否采用平台签名例外,都必须使用 updater 私钥、校验 updater feed,并明确标记平台未签名风险。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。 +- v0.1.0 另获准在 GitHub Actions 额度不可用时,从最终 `release` 上不可移动的 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS ad-hoc 制品;Windows 只能在额度恢复后从同一 tag/SHA 后补。macOS 先行阶段必须发布 updater `.sig`、checksum、provenance、`UNSIGNED-PLATFORM` 与 Windows pending 说明,但不得发布或替换生产 `latest.json`;只有 Windows `NotSigned` 制品和联合 feed 均验证通过后才能激活自动更新源。该本地/分阶段例外不适用于后续版本。 ## 分支与发布治理 @@ -57,4 +59,4 @@ npm run desktop:build:app 本地缓存相关变更至少执行 `runtime:check`、`desktop:release:check`、`ui:contract`、`type-check`、`test:unit` 和 `build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。 -正式桌面发布构建必须使用组织批准的 updater 签名私钥;macOS 必须完成 Apple 签名/公证,Windows 必须完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。未签名或 ad-hoc 构建只能作为内部验证构建描述。 +正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、发布说明、校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。该例外不自动适用于后续版本。 diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index de5f7d78..0c790457 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -28,14 +28,14 @@ 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 已签名和公证。 -- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 对应用和安装器均返回 `Valid`。 +- [ ] 在正式 release tag 环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、当前精确版本的平台签名策略、updater 私钥和生产 artifact HTTPS 基址齐备。 +- [ ] macOS app 已完成 Apple 签名/公证;若当前精确版本获批平台签名例外,则已验证 `Signature=adhoc`、确认未公证并附带 Gatekeeper 风险说明。 +- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 返回 `Valid`;若当前精确版本获批例外,则应用和安装器必须返回 `NotSigned` 并附带 SmartScreen 风险说明。 - [ ] 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:macos-release -- --ci`。 -- [ ] 设置 Windows PFX、密码、`WINDOWS_TIMESTAMP_URL` 和 updater 私钥后,以 `REQUIRE_WINDOWS_SIGNING=true` 执行 readiness,再执行 `npm run desktop:build:windows-release -- --ci`。 +- [ ] 始终设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 `REQUIRE_UPDATER_SIGNING=true`;默认 macOS 路径再设置 Apple 变量和 `REQUIRE_DESKTOP_SIGNING=true`,例外路径则设置 `DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception`、`ALLOW_UNSIGNED_PLATFORM_RELEASE=true` 并执行 `npm run desktop:build:macos-unsigned-release -- --ci`。 +- [ ] 默认 Windows 路径设置 PFX、密码、`WINDOWS_TIMESTAMP_URL` 和 `REQUIRE_WINDOWS_SIGNING=true`;例外路径不得伪装签名状态,构建后必须对应用和安装器验证 `NotSigned`。 - [ ] 正式 updater feed 使用 `--artifact ` 和 `--platform-artifact windows-x86_64=` 汇总生成同一个 `latest.json` 与 `SHA256SUMS.txt`。 -- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir --require-platform windows-x86_64`,并确认两个平台的 updater artifact、`.sig`、安装包、checksum manifest 和 `latest.json` 均通过校验。 +- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir --require-platform windows-x86_64 --require-provenance`,并确认两个平台的 updater artifact、`.sig`、安装包、provenance、checksum manifest 和 `latest.json` 均通过校验。 - [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。 - [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。 @@ -70,7 +70,7 @@ npm run desktop:build:app - [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。 - [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。 - [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。 -- [ ] signed Desktop release candidate workflow 只允许从 `vX.Y.Z` tag 运行;macOS 和 Windows 原生 job 分别完成签名验证,再由聚合 job 生成包含 `darwin-aarch64`、`darwin-x86_64` 和 `windows-x86_64` 的 feed、checksum 清单和 verified release directory。 +- [ ] Desktop release candidate workflow 只允许从 `vX.Y.Z` tag 运行;先按精确版本解析平台签名策略,macOS 和 Windows 原生 job 分别验证实际签名状态,再由聚合 job 生成包含 `darwin-aarch64`、`darwin-x86_64` 和 `windows-x86_64` 的 feed、provenance、checksum 清单和 verified release directory。 人工复审还必须确认: @@ -88,7 +88,8 @@ npm run desktop:build:app | 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 | | 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 | | 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 | -| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | +| 构建默认服务器地址已注入 | 不适用 | 必测 | 首次启动使用 `VITE_DESKTOP_SERVER_URL`,不要求重复输入;运行时代码不含生产域名 | +| 构建默认地址和本地配置均缺失 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | | 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | | 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 | | 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 | @@ -276,6 +277,22 @@ npm run desktop:build:app 创建正式 tag 前仍必须由发布负责人确认: -- GitHub Actions 已配置 updater、Apple 与 Windows 签名 secrets,以及 versioned artifact base URL 和 Windows timestamp URL。 -- 组织 Windows 证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。 +- GitHub Actions 已配置 updater secrets 和 versioned artifact base URL;默认平台签名路径还必须配置 Apple 与 Windows secrets 和 Windows timestamp URL。 +- 若使用默认 Windows 签名路径,组织证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。 - macOS/Windows 原生 candidate job、联合 feed 校验、真实安装/升级和生产下载源上传全部通过。 + +## 13. 2026-07-17 v0.1.0 平台签名例外记录 + +经发布负责人明确批准,v0.1.0 可在无法提供 Apple Developer 和 Windows 组织代码签名凭据时受控分发。该批准仅覆盖精确版本 `0.1.0`: + +- `frontend/desktop-release-policy.json` 默认仍为 `signed`,仅将 v0.1.0 列入 `unsignedPlatformExceptions`;后续版本不会继承本例外。 +- macOS 使用 Tauri `signingIdentity: "-"` 生成 ad-hoc 签名 Universal app/DMG,必须通过 `codesign --verify` 并确认 `Signature=adhoc`,不执行或声称 Apple 公证。 +- Windows 应用和 NSIS 安装器不做 Authenticode 签名,必须通过 `Get-AuthenticodeSignature` 确认 `NotSigned`,不执行或声称 RFC 3161 时间戳。 +- 两端仍必须从同一 `v0.1.0` tag 和 SHA 构建,使用同一 updater 私钥生成 `.sig`,并通过联合 feed、checksum 和 provenance 校验。 +- GitHub Actions 额度不可用期间,发布负责人额外批准从最终 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS 制品;Windows 必须在恢复后从同一 tag/SHA 后补。 +- macOS 先行 Release 必须包含 DMG、updater 包及 `.sig`、checksum、provenance、`UNSIGNED-PLATFORM` 和 Windows pending 说明,且不得包含或替换生产 `latest.json`。 +- Windows `NotSigned` 实物和联合 feed 全部验证通过后,才允许激活生产 updater feed;tag 不得为补充 Windows 制品而移动。 +- 制品与 Actions artifact 名称必须含 `_UNSIGNED` 或 `UNSIGNED-PLATFORM`,发布目录必须携带 `UNSIGNED-PLATFORM-RELEASE.txt` 和 `DESKTOP-RELEASE-PROVENANCE.json`。 +- 受控分发说明必须明确提示 macOS Gatekeeper 与 Windows SmartScreen 警告;平台未签名制品不得描述为 Apple/Microsoft 信任或已公证/已 Authenticode 签名。 + +本地实物验证已完成:使用当前 updater 私钥成功构建 Universal `x86_64 arm64` macOS app、DMG、`.app.tar.gz` 和 `.sig`;`codesign --verify --deep --strict` 通过,签名详情为 `Signature=adhoc`、`TeamIdentifier=not set`,Gatekeeper 拒绝符合预期。Windows `NotSigned`、NSIS 和 updater `.sig` 仍须由 `windows-latest` 原生 job 在正式 tag 上验证。 diff --git a/docs/branch-governance.md b/docs/branch-governance.md index 29d9eacb..99471e7c 100644 --- a/docs/branch-governance.md +++ b/docs/branch-governance.md @@ -305,9 +305,27 @@ Before promoting `main` to `release`, confirm: - release checklist is complete - production initialization, build, and smoke verification are complete +- Desktop updater signatures are present and verified +- platform signing is either complete or the exact version has an approved, + checked-in exception in `frontend/desktop-release-policy.json`; the current + v0.1.0 exception requires macOS ad-hoc signing, unsigned Windows artifacts, + controlled distribution, and explicit trust warnings - rollback or remediation path is clear - release tag has been prepared +Platform signing remains the default for versions without an exact approved +exception. An exception never permits disabling Tauri updater signatures or +building Web, macOS, and Windows from different tags or commits. + +For v0.1.0 only, the release owner also approved a staged contingency while +hosted Actions capacity is unavailable: macOS may be built on a controlled +local macOS host from the immutable final `v0.1.0` tag and published first. +Windows remains pending and must later be built from that same tag and SHA. +The macOS-only release must carry checksums, provenance, updater signature, +platform-trust warnings, and an explicit Windows-pending notice. It must not +publish or replace production `latest.json`; updater feed activation waits for +combined macOS and Windows verification. + Reference: - [docs/guides/release-checklist.md](guides/release-checklist.md) diff --git a/docs/guides/branch-maintenance-sop-zh.md b/docs/guides/branch-maintenance-sop-zh.md index 98f2819a..dad6fabb 100644 --- a/docs/guides/branch-maintenance-sop-zh.md +++ b/docs/guides/branch-maintenance-sop-zh.md @@ -106,7 +106,9 @@ npm run desktop:build:app npm run desktop:build:app ``` -正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥;macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行 readiness 与 Universal 构建,Windows 设置 PFX 证书、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行 readiness、签名 NSIS 构建和 Authenticode 校验。两个平台通过后才能汇总正式 updater feed。 +正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥,updater 签名不可关闭。平台签名默认路径仍要求 macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行,Windows 设置 PFX、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行。只有 `frontend/desktop-release-policy.json` 对当前精确版本存在已批准例外时,workflow 才可改为 macOS ad-hoc、Windows Authenticode 未签名路径;例外制品必须标注 `UNSIGNED-PLATFORM`、生成 provenance 和风险说明。两个平台与联合 feed 校验通过后才能汇总正式 updater feed。 + +v0.1.0 额外批准 GitHub Actions 额度不可用时的分阶段应急路径:先在受控 macOS 主机从最终、不可移动的 `v0.1.0` tag/SHA 本地构建并上传 macOS ad-hoc 制品,随附 updater `.sig`、checksum、provenance、平台未签名警告和 Windows pending 说明;Windows 恢复后必须从同一 tag/SHA 后补。macOS 先行阶段不得发布或替换生产 `latest.json`,联合 feed 必须等待 Windows `NotSigned` 实物验证完成。 后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。 @@ -206,7 +208,7 @@ dev -> main - 前后端测试通过 - 网页端构建通过 - macOS 桌面端构建通过 -- Windows x64 NSIS 兼容性构建通过;正式发布时签名和时间戳验证通过 +- Windows x64 NSIS 兼容性构建通过;正式发布时平台签名和时间戳验证通过,或当前精确版本的已批准平台签名例外验证通过 - 数据库迁移经过验证 - 已知风险和回滚方式已记录 @@ -295,7 +297,9 @@ git tag -a v1.2.0 -m "CTMS v1.2.0" git push origin v1.2.0 ``` -网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。macOS 签名/公证、Windows 代码签名/RFC 3161 时间戳以及两个平台的 updater 签名制品必须全部验证通过后,才能分发安装包并最后原子替换生产 `latest.json`。 +网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。两个平台的 updater 签名制品必须始终验证通过;macOS 签名/公证和 Windows 代码签名/RFC 3161 时间戳必须验证通过,除非 `frontend/desktop-release-policy.json` 对该精确版本记录了已批准例外。采用例外时必须验证 macOS 确为 ad-hoc、Windows 确为 `NotSigned`,并随安装包分发 `UNSIGNED-PLATFORM` 风险说明、provenance 和 checksum,之后才能最后原子替换生产 `latest.json`。 + +若执行 v0.1.0 的已批准分阶段应急路径,首次 macOS Release 可先于 Windows 发布,但 tag/SHA 不得移动;首次 Release 不包含 `latest.json`,并必须明确标记 Windows pending。Windows 后补且联合 feed 验证通过后,才允许按上述顺序激活生产 updater feed。 发布记录至少包含: @@ -450,6 +454,7 @@ git log --oneline --decorate --graph --all -30 - [ ] `npm run build` 通过 - [ ] `npm run desktop:build:app` 通过 - [ ] 正式桌面发布构建已使用 updater 签名私钥执行 +- [ ] 平台签名已验证,或当前精确版本已在 `frontend/desktop-release-policy.json` 获批例外且 `UNSIGNED-PLATFORM` 风险说明、provenance 和 checksum 齐备 - [ ] 数据库迁移与回滚方案确认 - [ ] 发布说明完成 - [ ] `main -> release` 合并完成 diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md index 23fee5ae..b854e2ec 100644 --- a/docs/guides/client-release.md +++ b/docs/guides/client-release.md @@ -87,7 +87,11 @@ npm run desktop:build:app `release:env:check` verifies build channel and commit metadata, and becomes platform-strict with `REQUIRE_DESKTOP_SIGNING=true` on macOS or -`REQUIRE_WINDOWS_SIGNING=true` on Windows. +`REQUIRE_WINDOWS_SIGNING=true` on Windows. Formal native jobs also set +`REQUIRE_UPDATER_SIGNING=true`, `DESKTOP_RELEASE_PLATFORM`, and the +version-derived `DESKTOP_PLATFORM_SIGNING_MODE`. The exact-version policy in +`frontend/desktop-release-policy.json` is checked with +`npm run desktop:release-policy:check`. `desktop:release:check` statically verifies the Tauri bundle, updater public key, CSP, capability scopes, command allowlist, query-token ban, generic system notification boundary, CI gate coverage, and secure session token boundary. The @@ -132,10 +136,10 @@ The release pipeline must: 1. build from the accepted release tag and commit; 2. build the Universal macOS app/DMG and Windows x64 NSIS installer from that tag; -3. sign and notarize macOS, and Authenticode-sign Windows with an RFC 3161 timestamp; +3. use Apple signing/notarization and Windows Authenticode/RFC 3161 signing by default, or use a checked-in exact-version exception that constrains macOS to ad-hoc signing and Windows to Authenticode-unsigned output; 4. produce macOS `.app.tar.gz` and Windows `.nsis.zip` updater artifacts plus their `.sig` files with the shared updater private key; -5. generate one combined `latest.json` and checksum manifest with `npm run desktop:update-feed:create`; -6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir --require-platform windows-x86_64`; +5. generate `DESKTOP-RELEASE-PROVENANCE.json`, one combined `latest.json`, and a checksum manifest; +6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir --require-platform windows-x86_64 --require-provenance`; 7. upload immutable artifacts first; 8. atomically replace `latest.json` last. @@ -143,22 +147,48 @@ For Universal macOS artifacts, `latest.json` must provide both `darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal update package. -The same feed must also contain `windows-x86_64`, pointing to the signed NSIS -`.nsis.zip` updater package. The Windows `.exe` installer is distributed next -to the updater package but is not used as the updater URL. +The same feed must also contain `windows-x86_64`, pointing to the updater-signed +NSIS `.nsis.zip` package. The Windows `.exe` installer is distributed next to +the updater package but is not used as the updater URL. Tauri updater signing is +mandatory in both platform-signed and platform-signing-exception modes. -The signed release candidate workflow lives at +The formal 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. +Platform signing defaults to `signed`. An exception is allowed only when +`frontend/desktop-release-policy.json` names the exact product version and +records release-owner approval. The current v0.1.0 exception uses macOS ad-hoc +signing and unsigned Windows application/installer binaries. Its artifact names +and release directory contain `UNSIGNED-PLATFORM`; the directory must include +`UNSIGNED-PLATFORM-RELEASE.txt`, `DESKTOP-RELEASE-PROVENANCE.json`, and +`SHA256SUMS.txt`. This does not establish Apple or Microsoft publisher trust, +and Gatekeeper or SmartScreen warnings are expected. Later versions return to +the signed default unless separately approved. + +### v0.1.0 staged macOS contingency + +The release owner approved one additional v0.1.0 contingency for unavailable +hosted Actions capacity. A controlled local macOS host may build the macOS +ad-hoc artifacts from the immutable final `v0.1.0` tag and publish them first. +That initial GitHub Release must include the DMG, updater package and `.sig`, +`SHA256SUMS.txt`, provenance, the unsigned-platform warning, and an explicit +Windows-pending notice. Windows must later be built from the same tag and SHA. + +The staged macOS release is an installer distribution, not an activated +cross-platform updater release. Do not upload or replace production +`latest.json` until the Windows `NotSigned` installer/updater has been built and +the combined macOS/Windows feed passes full verification. This fallback is +limited to v0.1.0 and does not authorize local formal builds for later versions. + ## Windows Release and Internal Validation Windows x64 NSIS is an approved formal Desktop target. Formal Windows builds run in `.github/workflows/desktop-release-candidate.yml` from the same exact -`vX.Y.Z` tag and SHA as Web and macOS. They must: +`vX.Y.Z` tag and SHA as Web and macOS. The default signed path must: - import a Base64-encoded PFX from `WINDOWS_CERTIFICATE` using `WINDOWS_CERTIFICATE_PASSWORD`; @@ -170,6 +200,12 @@ run in `.github/workflows/desktop-release-candidate.yml` from the same exact - publish the signed NSIS `.exe`, `.nsis.zip`, and `.nsis.zip.sig` into the combined verified Desktop release directory. +For an approved exact-version unsigned-platform exception, the Windows job +must instead leave the application and installer Authenticode-unsigned, require +`Get-AuthenticodeSignature` to return `NotSigned`, append `_UNSIGNED` to the +installer and updater artifact names, and still create and verify the updater +`.sig`. A certificate secret and timestamp URL are not required in this mode. + Windows release validation also covers: - WebView2 runtime prerequisite behavior; @@ -186,19 +222,33 @@ only, runs on `windows-latest`, injects `VITE_BUILD_CHANNEL` and and Desktop safety gates, builds an unsigned NSIS installer with updater artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub Actions artifact. This workflow is for internal compatibility verification -only; it must not generate `latest.json`, update feeds, signed release +only; it must not generate `latest.json`, update feeds, verified release directories, or formal Windows release artifacts. A successful internal build -does not substitute for the signed tag-only release workflow. +does not substitute for the tag-only formal workflow, including when that +formal workflow uses an approved platform-signing exception. -The formal workflow requires these organization settings: +The formal workflow always requires these organization settings: -- secrets: `TAURI_SIGNING_PRIVATE_KEY`, - `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, `WINDOWS_CERTIFICATE`, and - `WINDOWS_CERTIFICATE_PASSWORD`; -- variable: `WINDOWS_TIMESTAMP_URL`; +- secrets: `TAURI_SIGNING_PRIVATE_KEY` and + `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`; +- client default CTMS origin: repository variable `VITE_DESKTOP_SERVER_URL`; - shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the manual workflow input. +`VITE_DESKTOP_SERVER_URL` must be an HTTPS origin without credentials, path, +query, or fragment. Vite embeds it as the Desktop client's first-run default; +the runtime source must not contain the production hostname. A user can still +override this default in Desktop server settings, where the persisted manual +value takes precedence and switching origins clears the existing session and +server-scoped client state. Do not reuse this value as +`DESKTOP_UPDATE_BASE_URL`: the latter points to immutable updater artifacts, +not the CTMS business API. + +The default signed path additionally requires `WINDOWS_CERTIFICATE`, +`WINDOWS_CERTIFICATE_PASSWORD`, and `WINDOWS_TIMESTAMP_URL`, plus the Apple +credentials documented by the release owner. The v0.1.0 exception does not +require those platform certificate settings. + The checked-in workflow implements the exportable PFX path. Confirm that the organization's certificate policy permits an exportable CI certificate before provisioning it. If the selected CA provides only hardware- or cloud-backed @@ -214,6 +264,7 @@ npm ci npm run version:check export VITE_BUILD_CHANNEL=release export VITE_BUILD_COMMIT="$(git rev-parse HEAD)" +export VITE_DESKTOP_SERVER_URL="https://ctms.example.com" npm run release:env:check npm run runtime:check npm run desktop:release:check @@ -224,16 +275,21 @@ npm run build npm run desktop:build:app export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY" export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD" -export REQUIRE_DESKTOP_SIGNING=true +export REQUIRE_UPDATER_SIGNING=true +export DESKTOP_RELEASE_PLATFORM=macos +export DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception +export ALLOW_UNSIGNED_PLATFORM_RELEASE=true npm run release:env:check npm run desktop:release-readiness:check -npm run desktop:build:macos-release -- --ci +npm run desktop:build:macos-unsigned-release -- --ci npm run desktop:update-feed:create -- --artifact --platform-artifact windows-x86_64= --include --include --base-url --output-dir -npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir --require-platform windows-x86_64 +npm run desktop:update-feed:check -- --feed /latest.json --artifacts-dir --require-platform windows-x86_64 --require-provenance ``` -The macOS and Windows signed builds run in their native CI jobs. macOS requires -the Apple signing/notarization credentials defined by the release owner; -Windows requires the PFX certificate/password and timestamp URL above. Both use -the same updater signing key and are aggregated only after both native jobs -pass. Unsigned internal builds are not formal distributions. +The macOS and Windows builds run in their native CI jobs and always use the same +updater signing key. In the default path, macOS requires Apple +signing/notarization credentials and Windows requires the PFX/password and +timestamp URL. In the approved v0.1.0 exception, macOS must verify +`Signature=adhoc`, Windows must verify `NotSigned`, and both must carry explicit +platform-trust warnings. Native artifacts are aggregated only after both jobs +and the combined updater feed pass. diff --git a/frontend/.env.example b/frontend/.env.example index f30d4cc2..d60e20df 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,6 +2,8 @@ VITE_RUNTIME_ENV=production VITE_ALLOW_INSECURE_DEV_LOGIN=false VITE_DEV_API_PROXY_TARGET=http://backend:8000 +# Default CTMS origin embedded in Desktop builds; users can override it in Desktop settings. +VITE_DESKTOP_SERVER_URL=https://ctms.example.com # Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry. VITE_HMR_CLIENT_PORT= VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 diff --git a/frontend/desktop-release-policy.json b/frontend/desktop-release-policy.json new file mode 100644 index 00000000..964cfb12 --- /dev/null +++ b/frontend/desktop-release-policy.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "defaultPlatformSigningMode": "signed", + "updaterSigningRequired": true, + "unsignedPlatformExceptions": [ + { + "version": "0.1.0", + "status": "approved", + "macos": "ad-hoc", + "windows": "unsigned", + "distribution": "controlled", + "macosLocalExactTagFallback": true, + "windowsDelivery": "deferred-same-tag", + "updaterFeedActivation": "after-combined-platform-verification", + "approvedOn": "2026-07-17", + "reason": "The v0.1.0 release owner cannot currently provide Apple Developer signing/notarization credentials or an organization Windows code-signing certificate, and hosted Windows build capacity is temporarily unavailable." + } + ] +} diff --git a/frontend/desktop-release-unsigned-warning.txt b/frontend/desktop-release-unsigned-warning.txt new file mode 100644 index 00000000..6148b6dd --- /dev/null +++ b/frontend/desktop-release-unsigned-warning.txt @@ -0,0 +1,12 @@ +CTMS DESKTOP PLATFORM SIGNING WARNING + +This release is distributed under the version-scoped v0.1.0 platform-signing exception. + +- The macOS application uses an ad-hoc signature and is not Apple-notarized. +- The Windows application and installer have no Authenticode signature or RFC 3161 timestamp. +- macOS Gatekeeper and Windows SmartScreen warnings are expected. +- Tauri updater artifacts remain cryptographically signed and must pass updater feed verification. +- Platform availability may be staged; production latest.json remains withheld until macOS and Windows artifacts from the same tag pass combined verification. +- Install only after verifying the release tag, source commit, SHA256SUMS.txt, and DESKTOP-RELEASE-PROVENANCE.json through a trusted channel. + +This exception does not apply to later versions unless they are separately approved in desktop-release-policy.json. diff --git a/frontend/desktop-release-windows-pending.txt b/frontend/desktop-release-windows-pending.txt new file mode 100644 index 00000000..69d06531 --- /dev/null +++ b/frontend/desktop-release-windows-pending.txt @@ -0,0 +1,9 @@ +CTMS v0.1.0 WINDOWS RELEASE PENDING + +This GitHub Release is the approved first stage of the v0.1.0 Desktop distribution. + +- macOS Universal artifacts are available and use an ad-hoc platform signature. +- Windows x64 NSIS artifacts are not yet available. +- Windows must later be built from the same immutable v0.1.0 tag and Git commit. +- Production latest.json is intentionally withheld until Windows NotSigned artifacts and the combined updater feed pass verification. +- Do not interpret this staged release as a complete cross-platform automatic-update release. diff --git a/frontend/package.json b/frontend/package.json index ca3a14c0..236a9a47 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,10 @@ "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:build:macos-unsigned-release": "tauri build --target universal-apple-darwin --bundles app,dmg --config '{\"bundle\":{\"macOS\":{\"signingIdentity\":\"-\"}}}'", "desktop:build:windows-release": "tauri build --bundles nsis", + "desktop:release-policy:check": "node scripts/resolve-desktop-release-policy.mjs", + "desktop:release-provenance:create": "node scripts/create-desktop-release-provenance.mjs", "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", diff --git a/frontend/scripts/create-desktop-release-provenance.mjs b/frontend/scripts/create-desktop-release-provenance.mjs new file mode 100644 index 00000000..a6213975 --- /dev/null +++ b/frontend/scripts/create-desktop-release-provenance.mjs @@ -0,0 +1,79 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const args = process.argv.slice(2); +const value = (name) => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +}; + +const policy = await loadDesktopReleasePolicy(); +const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy); +const tag = value("--tag") || process.env.GITHUB_REF_NAME; +const commit = value("--commit") || process.env.GITHUB_SHA; +const macosSigning = value("--macos-signing"); +const windowsSigning = value("--windows-signing"); +const releaseStage = value("--stage") || "complete"; +const outputPath = resolve( + frontendDir, + value("--output") || "src-tauri/target/desktop-release-feed/DESKTOP-RELEASE-PROVENANCE.json", +); + +const failures = []; +const assert = (condition, message) => { + if (!condition) failures.push(message); +}; + +assert(tag === `v${packageInfo.version}`, `Release provenance tag must be v${packageInfo.version}.`); +assert(/^[0-9a-f]{40}$/i.test(commit || ""), "Release provenance commit must be a full 40-character SHA."); +assert(macosSigning === resolution.macosSigning, `macOS signing mode must be ${resolution.macosSigning}.`); +assert(windowsSigning === resolution.windowsSigning, `Windows signing mode must be ${resolution.windowsSigning}.`); +assert(["complete", "macos-first"].includes(releaseStage), "Release provenance stage must be complete or macos-first."); +if (releaseStage === "macos-first") { + assert( + resolution.macosLocalExactTagFallback === true && resolution.windowsDelivery === "deferred-same-tag", + `v${packageInfo.version} does not approve a staged local macOS-first release.`, + ); +} + +if (failures.length > 0) { + throw new Error(`Desktop release provenance creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); +} + +const provenance = { + schemaVersion: 1, + product: "CTMS Desktop", + version: packageInfo.version, + tag, + commit, + platformSigningMode: resolution.platformSigningMode, + platformSigning: { + macos: macosSigning, + windows: windowsSigning, + }, + updaterSigning: "required-and-verified", + artifactLabel: resolution.artifactLabel, + distribution: resolution.distribution, + stage: releaseStage, + platformAvailability: + releaseStage === "macos-first" + ? { macos: "available", windows: "pending-same-tag" } + : { macos: "available", windows: "available" }, + updaterFeedActivation: + releaseStage === "macos-first" ? "withheld-pending-combined-verification" : "combined-platform-verification-required", + warnings: resolution.warningRequired + ? [ + "macOS is ad-hoc signed and not Apple-notarized; Gatekeeper warnings are expected.", + "Windows is not Authenticode-signed or RFC 3161 timestamped; SmartScreen warnings are expected.", + "Verify the release tag, commit, updater signatures, and SHA256SUMS.txt through a trusted channel before installation.", + ] + : [], +}; + +await mkdir(dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(provenance, null, 2)}\n`); +console.log(`Desktop release provenance created at ${outputPath}`); diff --git a/frontend/scripts/desktop-release-policy.mjs b/frontend/scripts/desktop-release-policy.mjs new file mode 100644 index 00000000..7d364b14 --- /dev/null +++ b/frontend/scripts/desktop-release-policy.mjs @@ -0,0 +1,97 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +export const desktopReleasePolicyPath = resolve(frontendDir, "desktop-release-policy.json"); + +const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; + +const validatePolicy = (policy) => { + const failures = []; + const assert = (condition, message) => { + if (!condition) failures.push(message); + }; + + assert(policy?.schemaVersion === 1, "desktop release policy schemaVersion must be 1."); + assert( + policy?.defaultPlatformSigningMode === "signed", + "desktop release policy must default platform signing to signed.", + ); + assert(policy?.updaterSigningRequired === true, "desktop release policy must always require updater signing."); + assert( + Array.isArray(policy?.unsignedPlatformExceptions), + "desktop release policy unsignedPlatformExceptions must be an array.", + ); + + const versions = new Set(); + for (const exception of policy?.unsignedPlatformExceptions || []) { + const prefix = `unsigned platform exception ${exception?.version || ""}`; + assert(semverPattern.test(exception?.version || ""), `${prefix} must use an exact X.Y.Z version.`); + assert(!versions.has(exception?.version), `${prefix} is duplicated.`); + versions.add(exception?.version); + assert(exception?.status === "approved", `${prefix} must have status approved.`); + assert(exception?.macos === "ad-hoc", `${prefix} must constrain macOS to ad-hoc signing.`); + assert(exception?.windows === "unsigned", `${prefix} must constrain Windows to unsigned.`); + assert(exception?.distribution === "controlled", `${prefix} must constrain distribution to controlled.`); + if (exception?.version === "0.1.0") { + assert(exception?.macosLocalExactTagFallback === true, `${prefix} must explicitly approve the local exact-tag macOS fallback.`); + assert(exception?.windowsDelivery === "deferred-same-tag", `${prefix} must defer Windows only from the same tag.`); + assert( + exception?.updaterFeedActivation === "after-combined-platform-verification", + `${prefix} must withhold the production updater feed until both platforms are verified.`, + ); + } + assert(/^\d{4}-\d{2}-\d{2}$/.test(exception?.approvedOn || ""), `${prefix} must record an approval date.`); + assert( + typeof exception?.reason === "string" && exception.reason.trim().length >= 30, + `${prefix} must record a substantive reason.`, + ); + } + + if (failures.length > 0) { + throw new Error(failures.join("\n")); + } +}; + +export const loadDesktopReleasePolicy = async () => { + const policy = JSON.parse(await readFile(desktopReleasePolicyPath, "utf8")); + validatePolicy(policy); + return policy; +}; + +export const resolveDesktopReleasePolicy = (version, policy) => { + if (!semverPattern.test(version || "")) { + throw new Error(`Desktop release version must use exact X.Y.Z semver; found ${version || ""}.`); + } + + const exception = policy.unsignedPlatformExceptions.find( + (candidate) => candidate.version === version && candidate.status === "approved", + ); + + if (!exception) { + return { + version, + platformSigningMode: "signed", + macosSigning: "apple-developer", + windowsSigning: "authenticode", + artifactLabel: "SIGNED-PLATFORM", + distribution: "formal", + warningRequired: false, + }; + } + + return { + version, + platformSigningMode: "unsigned-exception", + macosSigning: exception.macos, + windowsSigning: exception.windows, + artifactLabel: "UNSIGNED-PLATFORM", + distribution: exception.distribution, + warningRequired: true, + macosLocalExactTagFallback: exception.macosLocalExactTagFallback === true, + windowsDelivery: exception.windowsDelivery, + updaterFeedActivation: exception.updaterFeedActivation, + exception, + }; +}; diff --git a/frontend/scripts/resolve-desktop-release-policy.mjs b/frontend/scripts/resolve-desktop-release-policy.mjs new file mode 100644 index 00000000..3e35a345 --- /dev/null +++ b/frontend/scripts/resolve-desktop-release-policy.mjs @@ -0,0 +1,47 @@ +import { appendFile, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const args = process.argv.slice(2); +const value = (name) => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +}; + +const policy = await loadDesktopReleasePolicy(); +const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy); +const expectedTag = `v${packageInfo.version}`; + +if (args.includes("--require-tag")) { + if (process.env.GITHUB_REF_TYPE !== "tag" || process.env.GITHUB_REF_NAME !== expectedTag) { + throw new Error( + `Desktop release policy must be resolved from tag ${expectedTag}; found ${process.env.GITHUB_REF_TYPE || ""} ${process.env.GITHUB_REF_NAME || ""}.`, + ); + } +} + +const outputs = { + version: resolution.version, + platform_signing_mode: resolution.platformSigningMode, + macos_signing: resolution.macosSigning, + windows_signing: resolution.windowsSigning, + artifact_label: resolution.artifactLabel, + distribution: resolution.distribution, + warning_required: String(resolution.warningRequired), + updater_signing_required: String(policy.updaterSigningRequired), +}; + +const githubOutput = value("--github-output"); +if (githubOutput) { + await appendFile(githubOutput, `${Object.entries(outputs).map(([key, output]) => `${key}=${output}`).join("\n")}\n`); +} + +console.log("Desktop release policy check passed."); +console.log(` version: ${resolution.version}`); +console.log(` platform signing mode: ${resolution.platformSigningMode}`); +console.log(` macOS: ${resolution.macosSigning}`); +console.log(` Windows: ${resolution.windowsSigning}`); +console.log(" updater signing: required"); diff --git a/frontend/scripts/verify-desktop-release-readiness.mjs b/frontend/scripts/verify-desktop-release-readiness.mjs index 0989eccb..b699852d 100644 --- a/frontend/scripts/verify-desktop-release-readiness.mjs +++ b/frontend/scripts/verify-desktop-release-readiness.mjs @@ -2,10 +2,13 @@ import { execFileSync } from "node:child_process"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs"; const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const rootDir = resolve(frontendDir, ".."); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const desktopReleasePolicy = await loadDesktopReleasePolicy(); +const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy); const failures = []; const env = process.env; @@ -13,6 +16,10 @@ const fullShaPattern = /^[0-9a-f]{40}$/i; const expectedTag = `v${packageInfo.version}`; const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true"; const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true"; +const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true"; +const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true"; +const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE; +const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM; const requiredUpdaterEnv = ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"]; const requiredMacosEnv = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID"]; const requiredWindowsEnv = ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD"]; @@ -38,7 +45,7 @@ const gitMaybe = (args) => { }; const requireEnv = (name) => { - assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`); + assert(Boolean(env[name]), `${name} must be configured for desktop release readiness.`); }; const validateBaseUrl = () => { @@ -63,6 +70,25 @@ const validateBaseUrl = () => { ); }; +const validateClientServerUrl = () => { + const raw = env.VITE_DESKTOP_SERVER_URL; + requireEnv("VITE_DESKTOP_SERVER_URL"); + if (!raw) return; + + let url; + try { + url = new URL(raw); + } catch (error) { + fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`); + return; + } + + assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS."); + assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials."); + assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path."); + assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments."); +}; + const validateWindowsTimestampUrl = () => { const raw = env.WINDOWS_TIMESTAMP_URL; requireEnv("WINDOWS_TIMESTAMP_URL"); @@ -95,19 +121,54 @@ if (headSha && env.VITE_BUILD_COMMIT) { assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit."); } -assert( - requiresMacosSigning || requiresWindowsSigning, - "Release readiness requires REQUIRE_DESKTOP_SIGNING=true or REQUIRE_WINDOWS_SIGNING=true.", -); assert( !(requiresMacosSigning && requiresWindowsSigning), "macOS and Windows signing readiness must be checked in their native jobs.", ); +assert( + desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows", + "DESKTOP_RELEASE_PLATFORM must be macos or windows.", +); +assert( + desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode, + `DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`, +); +assert( + desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning, + "Release readiness requires REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.", +); for (const name of requiredUpdaterEnv) { requireEnv(name); } +if (desktopPlatformSigningMode === "signed") { + assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE."); + assert( + (desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) || + (desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning), + "Signed readiness must enable only the matching native platform signing requirement.", + ); +} + +if (desktopPlatformSigningMode === "unsigned-exception") { + assert( + desktopReleaseResolution.platformSigningMode === "unsigned-exception", + `v${packageInfo.version} has no approved unsigned platform release exception.`, + ); + assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true."); + assert( + !requiresMacosSigning && !requiresWindowsSigning, + "Unsigned platform readiness must not claim Apple or Windows platform signing.", + ); + if (desktopReleasePlatform === "macos") { + assert(process.platform === "darwin", "The macOS ad-hoc readiness check must run on macOS."); + } + if (desktopReleasePlatform === "windows") { + assert(process.platform === "win32", "The unsigned Windows readiness check must run on Windows."); + } +} + if (requiresMacosSigning) { assert(process.platform === "darwin", "Signed macOS desktop release readiness must run on macOS."); for (const name of requiredMacosEnv) { @@ -131,6 +192,7 @@ if (requiresWindowsSigning) { } validateBaseUrl(); +validateClientServerUrl(); if (failures.length > 0) { console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index bc9ec248..019b3f2b 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -62,7 +62,7 @@ const verifyTauriConfig = async () => { assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution."); assert( tauriConfig.bundle?.createUpdaterArtifacts === true, - "Tauri must create updater artifacts for signed desktop release builds.", + "Tauri must create signed updater artifacts for formal desktop release builds.", ); assert( typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80, @@ -387,12 +387,28 @@ const verifyUpdaterBoundary = async () => { assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials."); }; +const verifyDesktopServerConfigurationBoundary = async () => { + const source = await readFile(resolve(sourceDir, "runtime/desktopServerConfig.ts"), "utf8"); + assert( + source.includes("import.meta.env.VITE_DESKTOP_SERVER_URL"), + "Desktop server defaults must come from VITE_DESKTOP_SERVER_URL.", + ); + assert(!source.includes("ctms.huapont.cn"), "The production CTMS origin must not be hard-coded in Desktop runtime source."); + assert( + source.includes("getStoredDesktopServerUrl() || getDefaultDesktopServerUrl()"), + "A manually stored Desktop server URL must override the build-time default.", + ); +}; + const verifyWorkflowGates = async () => { const packageInfo = await readJson(resolve(frontendDir, "package.json")); assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0."); const requiredScripts = [ "desktop:build:macos-release", + "desktop:build:macos-unsigned-release", "desktop:build:windows-release", + "desktop:release-policy:check", + "desktop:release-provenance:create", "desktop:update-feed:create", "desktop:update-feed:check", "desktop:release-readiness:check", @@ -403,6 +419,44 @@ const verifyWorkflowGates = async () => { assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`); } + const provenanceSource = await readFile(resolve(frontendDir, "scripts/create-desktop-release-provenance.mjs"), "utf8"); + assert(provenanceSource.includes('"macos-first"'), "Release provenance must support the approved v0.1.0 macOS-first stage."); + assert( + provenanceSource.includes("withheld-pending-combined-verification"), + "Staged macOS provenance must record that updater feed activation is withheld.", + ); + const windowsPendingNotice = await readFile(resolve(frontendDir, "desktop-release-windows-pending.txt"), "utf8"); + assert( + windowsPendingNotice.includes("same immutable v0.1.0 tag") && windowsPendingNotice.includes("latest.json is intentionally withheld"), + "The staged v0.1.0 release must include an explicit Windows-pending and updater-feed notice.", + ); + + const releasePolicy = await readJson(resolve(frontendDir, "desktop-release-policy.json")); + assert(releasePolicy.schemaVersion === 1, "Desktop release policy schemaVersion must be 1."); + assert( + releasePolicy.defaultPlatformSigningMode === "signed", + "Desktop release policy must default future versions to signed platform releases.", + ); + assert(releasePolicy.updaterSigningRequired === true, "Desktop release policy must require updater signing."); + const currentUnsignedException = releasePolicy.unsignedPlatformExceptions?.find( + (exception) => exception.version === packageInfo.version, + ); + if (packageInfo.version === "0.1.0") { + assert(Boolean(currentUnsignedException), "Desktop release policy must record the approved v0.1.0 exception."); + assert( + currentUnsignedException?.macosLocalExactTagFallback === true && + currentUnsignedException?.windowsDelivery === "deferred-same-tag" && + currentUnsignedException?.updaterFeedActivation === "after-combined-platform-verification", + "The v0.1.0 exception must constrain local macOS staging, deferred Windows delivery, and updater feed activation.", + ); + } + if (currentUnsignedException) { + assert( + currentUnsignedException.macos === "ad-hoc" && currentUnsignedException.windows === "unsigned", + `The v${packageInfo.version} exception must use macOS ad-hoc and unsigned Windows modes.`, + ); + } + const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8"); const requiredCommands = [ "npm run version:check", @@ -421,6 +475,7 @@ 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."); + assert(workflow.includes("VITE_DESKTOP_SERVER_URL"), "Client quality gates workflow must inject VITE_DESKTOP_SERVER_URL."); assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs."); const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8"); @@ -429,7 +484,18 @@ const verifyWorkflowGates = async () => { "runs-on: windows-latest", "REQUIRE_DESKTOP_SIGNING", "REQUIRE_WINDOWS_SIGNING", + "REQUIRE_UPDATER_SIGNING", + "ALLOW_UNSIGNED_PLATFORM_RELEASE", + "DESKTOP_PLATFORM_SIGNING_MODE", + "desktop:release-policy:check", + "desktop:build:macos-unsigned-release", + "Signature=adhoc", + '"NotSigned"', + "UNSIGNED-PLATFORM", + "DESKTOP-RELEASE-PROVENANCE.json", + "--require-provenance", "TAURI_SIGNING_PRIVATE_KEY", + "VITE_DESKTOP_SERVER_URL", "APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID", @@ -463,6 +529,7 @@ const verifyWorkflowGates = async () => { "runs-on: windows-latest", "VITE_BUILD_CHANNEL", "VITE_BUILD_COMMIT", + "VITE_DESKTOP_SERVER_URL", "npm run release:env:check", "npm run version:check", "npm run runtime:check", @@ -521,6 +588,7 @@ await verifySourceSafety(); await verifyNotificationBoundary(); await verifySessionBoundary(); await verifyUpdaterBoundary(); +await verifyDesktopServerConfigurationBoundary(); await verifyOnlyOfficeBoundary(); await verifyWorkflowGates(); diff --git a/frontend/scripts/verify-desktop-update-feed.mjs b/frontend/scripts/verify-desktop-update-feed.mjs index 0b1a5719..96b5b626 100644 --- a/frontend/scripts/verify-desktop-update-feed.mjs +++ b/frontend/scripts/verify-desktop-update-feed.mjs @@ -2,9 +2,12 @@ import { access, readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs"; const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const desktopReleasePolicy = await loadDesktopReleasePolicy(); +const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy); const failures = []; const args = process.argv.slice(2); @@ -21,6 +24,11 @@ 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 requiresProvenance = args.includes("--require-provenance"); +const provenancePath = + optionValue("--provenance") || + process.env.DESKTOP_RELEASE_PROVENANCE || + (artifactDir ? resolve(artifactDir, "DESKTOP-RELEASE-PROVENANCE.json") : undefined); const requiredPlatforms = new Set([ "darwin-aarch64", "darwin-x86_64", @@ -94,6 +102,56 @@ const assertManifestEntries = async (checksums) => { } }; +const verifyProvenance = async (checksums) => { + if (!requiresProvenance) return; + if (!provenancePath) { + fail("Desktop release provenance path is required."); + return; + } + + let provenance; + try { + provenance = JSON.parse(await readFile(provenancePath, "utf8")); + } catch (error) { + fail(`Cannot read desktop release provenance ${provenancePath}: ${error.message}`); + return; + } + + assert(provenance.schemaVersion === 1, "Desktop release provenance schemaVersion must be 1."); + assert(provenance.version === packageInfo.version, "Desktop release provenance version must match the package version."); + assert(provenance.tag === `v${packageInfo.version}`, "Desktop release provenance tag must match the package version."); + assert(/^[0-9a-f]{40}$/i.test(provenance.commit || ""), "Desktop release provenance must contain a full commit SHA."); + assert( + provenance.platformSigningMode === desktopReleaseResolution.platformSigningMode, + `Desktop release provenance platformSigningMode must be ${desktopReleaseResolution.platformSigningMode}.`, + ); + assert( + provenance.platformSigning?.macos === desktopReleaseResolution.macosSigning, + `Desktop release provenance macOS signing must be ${desktopReleaseResolution.macosSigning}.`, + ); + assert( + provenance.platformSigning?.windows === desktopReleaseResolution.windowsSigning, + `Desktop release provenance Windows signing must be ${desktopReleaseResolution.windowsSigning}.`, + ); + assert( + provenance.updaterSigning === "required-and-verified", + "Desktop release provenance must record required-and-verified updater signing.", + ); + assert( + provenance.artifactLabel === desktopReleaseResolution.artifactLabel, + `Desktop release provenance artifactLabel must be ${desktopReleaseResolution.artifactLabel}.`, + ); + if (desktopReleaseResolution.warningRequired) { + assert( + Array.isArray(provenance.warnings) && provenance.warnings.length >= 2, + "Unsigned platform release provenance must include installation trust warnings.", + ); + } + if (artifactDir) { + await assertChecksum(checksums, provenancePath, "desktop release provenance"); + } +}; + let feed; try { feed = JSON.parse(await readFile(feedPath, "utf8")); @@ -104,6 +162,7 @@ try { if (feed) { const checksums = await readChecksumManifest(); await assertManifestEntries(checksums); + await verifyProvenance(checksums); const normalizedFeedVersion = String(feed.version || "").replace(/^v/, ""); const platforms = feed.platforms || {}; const darwinArm = platforms["darwin-aarch64"]; diff --git a/frontend/scripts/verify-release-build-env.mjs b/frontend/scripts/verify-release-build-env.mjs index 678b9d76..e1206851 100644 --- a/frontend/scripts/verify-release-build-env.mjs +++ b/frontend/scripts/verify-release-build-env.mjs @@ -2,10 +2,13 @@ import { execFileSync } from "node:child_process"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs"; const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const rootDir = resolve(frontendDir, ".."); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const desktopReleasePolicy = await loadDesktopReleasePolicy(); +const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy); const failures = []; const allowedChannels = new Set(["dev", "main", "release", "local"]); @@ -20,6 +23,36 @@ const isTagBuild = env.GITHUB_REF_TYPE === "tag"; const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release"; const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true"; const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true"; +const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true"; +const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true"; +const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE; +const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM; +const isNativeDesktopRelease = Boolean( + desktopReleasePlatform || + desktopPlatformSigningMode || + requiresMacosSigning || + requiresWindowsSigning || + requiresUpdaterSigning, +); + +const validateDesktopServerUrl = () => { + const raw = env.VITE_DESKTOP_SERVER_URL; + requireEnv("VITE_DESKTOP_SERVER_URL"); + if (!raw) return; + + let url; + try { + url = new URL(raw); + } catch (error) { + fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`); + return; + } + + assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS."); + assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials."); + assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path."); + assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments."); +}; const fail = (message) => failures.push(message); const assert = (condition, message) => { @@ -27,7 +60,7 @@ const assert = (condition, message) => { }; const requireEnv = (name) => { - assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`); + assert(Boolean(env[name]), `${name} must be configured for this desktop release build.`); }; const gitHead = () => { @@ -47,6 +80,7 @@ assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...al if (isCi || isReleaseBuild) { assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL."); assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT."); + validateDesktopServerUrl(); } if (env.GITHUB_SHA) { @@ -74,13 +108,59 @@ assert( "macOS and Windows signing requirements must be checked in their native jobs.", ); -if (requiresMacosSigning) { - assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS."); +if (isNativeDesktopRelease) { + assert(isReleaseBuild, "Native desktop release settings may only be used for release builds."); + assert( + desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning, + "Formal desktop releases must set REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.", + ); + assert( + desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode, + `DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`, + ); + assert( + desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows", + "DESKTOP_RELEASE_PLATFORM must be macos or windows for native desktop release builds.", + ); if (isCi) { - assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag."); + assert(isTagBuild, "Native desktop release candidate builds in CI must run from a release tag."); } +} + +if (requiresUpdaterSigning) { requireEnv("TAURI_SIGNING_PRIVATE_KEY"); requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"); +} + +if (desktopPlatformSigningMode === "signed") { + assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE."); + assert( + (desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) || + (desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning), + "Signed native release jobs must enable only their matching platform signing requirement.", + ); +} + +if (desktopPlatformSigningMode === "unsigned-exception") { + assert( + desktopReleaseResolution.platformSigningMode === "unsigned-exception", + `v${packageInfo.version} has no approved unsigned platform release exception.`, + ); + assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true."); + assert( + !requiresMacosSigning && !requiresWindowsSigning, + "Unsigned platform exceptions must not claim Apple or Windows platform signing.", + ); + if (desktopReleasePlatform === "macos") { + assert(process.platform === "darwin", "The macOS ad-hoc release exception must run on macOS."); + } + if (desktopReleasePlatform === "windows") { + assert(process.platform === "win32", "The unsigned Windows release exception must run on Windows."); + } +} + +if (requiresMacosSigning) { + assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS."); requireEnv("APPLE_ID"); requireEnv("APPLE_PASSWORD"); requireEnv("APPLE_TEAM_ID"); @@ -95,11 +175,6 @@ if (requiresMacosSigning) { if (requiresWindowsSigning) { assert(process.platform === "win32", "Signed Windows desktop release builds must run on Windows."); - if (isCi) { - assert(isTagBuild, "Signed Windows desktop release candidate builds in CI must run from a release tag."); - } - requireEnv("TAURI_SIGNING_PRIVATE_KEY"); - requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"); requireEnv("WINDOWS_CERTIFICATE"); requireEnv("WINDOWS_CERTIFICATE_PASSWORD"); requireEnv("WINDOWS_TIMESTAMP_URL"); diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index 6b14a58a..3c315079 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local"; readonly VITE_BUILD_COMMIT?: string; + readonly VITE_DESKTOP_SERVER_URL?: string; } interface ImportMeta { diff --git a/frontend/src/runtime/desktopServerConfig.test.ts b/frontend/src/runtime/desktopServerConfig.test.ts index 2aed5f49..8888412d 100644 --- a/frontend/src/runtime/desktopServerConfig.test.ts +++ b/frontend/src/runtime/desktopServerConfig.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DESKTOP_SERVER_URL_CHANGED_EVENT, DESKTOP_SERVER_URL_KEY, + getDefaultDesktopServerUrl, getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl, @@ -31,6 +32,7 @@ const createMemoryStorage = (): Storage => { }; beforeEach(() => { + vi.stubEnv("VITE_DESKTOP_SERVER_URL", ""); Object.defineProperty(window, "localStorage", { value: createMemoryStorage(), configurable: true, @@ -40,6 +42,7 @@ beforeEach(() => { afterEach(() => { window.localStorage.clear(); setTauriRuntime(false); + vi.unstubAllEnvs(); }); describe("desktop server config", () => { @@ -70,6 +73,23 @@ describe("desktop server config", () => { expect(listener).toHaveBeenCalledOnce(); }); + it("uses the build-time default while allowing a persisted manual override", () => { + vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com"); + + expect(getDefaultDesktopServerUrl()).toBe("https://default.ctms.example.com/"); + expect(getDesktopServerUrl()).toBe("https://default.ctms.example.com/"); + + setDesktopServerUrl("https://manual.ctms.example.com"); + expect(getDesktopServerUrl()).toBe("https://manual.ctms.example.com/"); + }); + + it("ignores an invalid build-time default", () => { + vi.stubEnv("VITE_DESKTOP_SERVER_URL", "http://public.ctms.example.com"); + + expect(getDefaultDesktopServerUrl()).toBeNull(); + expect(getDesktopServerUrl()).toBeNull(); + }); + it("requires a server URL only inside the Tauri runtime", () => { expect(shouldRequireDesktopServerUrl()).toBe(false); setTauriRuntime(true); @@ -77,4 +97,11 @@ describe("desktop server config", () => { setDesktopServerUrl("https://ctms.example.com"); expect(shouldRequireDesktopServerUrl()).toBe(false); }); + + it("does not require first-run configuration when a valid build-time default exists", () => { + vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com"); + setTauriRuntime(true); + + expect(shouldRequireDesktopServerUrl()).toBe(false); + }); }); diff --git a/frontend/src/runtime/desktopServerConfig.ts b/frontend/src/runtime/desktopServerConfig.ts index 251d6204..caddbb4a 100644 --- a/frontend/src/runtime/desktopServerConfig.ts +++ b/frontend/src/runtime/desktopServerConfig.ts @@ -51,13 +51,22 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida return { ok: true, url: `${parsed.origin}/` }; }; -export const getDesktopServerUrl = (): string | null => { +export const getDefaultDesktopServerUrl = (): string | null => { + const configured = import.meta.env.VITE_DESKTOP_SERVER_URL?.trim(); + if (!configured) return null; + const result = normalizeDesktopServerUrl(configured); + return result.ok ? result.url : null; +}; + +const getStoredDesktopServerUrl = (): string | null => { const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY); if (!stored) return null; const result = normalizeDesktopServerUrl(stored); return result.ok ? result.url : null; }; +export const getDesktopServerUrl = (): string | null => getStoredDesktopServerUrl() || getDefaultDesktopServerUrl(); + export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl()); export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => { @@ -74,7 +83,7 @@ export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationRe export const clearDesktopServerUrl = (): void => { const previous = getDesktopServerUrl(); getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY); - emitServerUrlChanged(previous, null); + emitServerUrlChanged(previous, getDesktopServerUrl()); }; export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl(); diff --git a/frontend/src/runtime/index.ts b/frontend/src/runtime/index.ts index 702fb7a1..a8863bd9 100644 --- a/frontend/src/runtime/index.ts +++ b/frontend/src/runtime/index.ts @@ -2,6 +2,7 @@ export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./c export { clearDesktopServerUrl, DESKTOP_SERVER_URL_CHANGED_EVENT, + getDefaultDesktopServerUrl, getDesktopServerUrl, hasDesktopServerUrl, normalizeDesktopServerUrl,