Compare commits

1 Commits

Author SHA1 Message Date
chengchengzhou7 403776cc1c release(main): 同步 v0.1.0 updater 信任根 (#9)
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
* docs: add desktop project plan

* feat(desktop): implement phase 1 tauri client

* refactor(client): unify web and desktop release workflow

* feat(desktop): implement phase 2 native capabilities

* 完善桌面端交互体验与发布检查

* 完善桌面端界面、发布检查与邮箱域名同步

* fix(deploy): 修复数据库初始化复用旧镜像

* fix(deploy): 增加部署更新实时进度

* fix(auth): 支持无邮箱后缀时手动输入

* feat(desktop): 稳定桌面端界面与文件操作反馈

- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试

* feat(desktop): 支持桌面端三十天免登录

* 完善桌面端发布稳定化门禁

* 完善桌面端端到端回归收口

* 补齐桌面端附件文件流回归

* 完善桌面端回归与安全边界复审

* 完善桌面体验与系统通知收口

* feat(desktop): 收口桌面工作台视觉与活动反馈

* 优化桌面端界面布局

* style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标

* 功能(桌面端):增加在线辅助本地缓存

* 优化桌面端标签导航与后台交互

* ci: 新增 Windows 桌面端内测构建

* fix: 修复桌面检查脚本的 Windows 路径判断

* test: 兼容 Windows 换行的桌面布局断言

* test: 兼容 Windows 换行的路由断言

* ci: 修复 Windows 内测构建配置传参

* ci: 避免 Windows 安装器构建交互等待

* 修复桌面端界面显示与稳定性问题

* feat(网页端): 完善登录后工作台与项目管理体验

* docs(desktop): 精简桌面端约束入口

* feat(admin): 完善审计访问上下文与后台布局

* fix(git): 跟踪原生图标资源

* fix(web): 修正工作台端侧标识

* feat(监控): 完善系统监控、登录状态与访问审计能力

* 修复(权限管理):统一 PM 系统导航与权限校验

* feat(工作台): 优化入口布局与连接安全状态

* feat(桌面与监控): 完善工作台导航和登录活动定位

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖

* 功能(文档与桌面):完善文件预览下载与客户端构建基线

- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档

* 功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验

新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。

* feat(collaboration): 完善在线文档协作与通知闭环

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。

* refactor(frontend): 按需加载页面并清理未使用代码

- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。

* fix(审计): 移除共享库审计与预览噪声

* 功能(提醒):统一项目提醒中心与桌面通知链路

增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。

* feat(deploy): 默认安装 ONLYOFFICE 标准组件

* build(release): 加固 v0.1.0 桌面发布链路 (#3)

* build(release): 轮换 v0.1.0 updater 公钥 (#7)
2026-07-17 10:58:22 +08:00
16 changed files with 85 additions and 649 deletions
+23 -118
View File
@@ -19,35 +19,8 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: 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: macos-release-candidate:
name: macOS release candidate name: Signed macOS release candidate
needs: release-policy
runs-on: macos-latest runs-on: macos-latest
defaults: defaults:
run: run:
@@ -56,11 +29,7 @@ jobs:
VITE_BUILD_CHANNEL: release VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }} VITE_BUILD_COMMIT: ${{ github.sha }}
RELEASE_BUILD: "true" RELEASE_BUILD: "true"
DESKTOP_RELEASE_PLATFORM: macos REQUIRE_DESKTOP_SIGNING: "true"
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 }} 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: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -85,7 +54,7 @@ jobs:
shell: bash shell: bash
run: | run: |
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
echo "Desktop release candidates must run from a vX.Y.Z tag." echo "Signed desktop release candidates must run from a vX.Y.Z tag."
exit 1 exit 1
fi fi
expected_tag="v$(node -p "require('./package.json').version")" expected_tag="v$(node -p "require('./package.json').version")"
@@ -108,7 +77,7 @@ jobs:
- name: Check release build metadata and signing environment - name: Check release build metadata and signing environment
run: npm run release:env:check run: npm run release:env:check
- name: Check desktop release readiness - name: Check signed desktop release readiness
run: npm run desktop:release-readiness:check run: npm run desktop:release-readiness:check
- name: Check synchronized client version - name: Check synchronized client version
@@ -132,14 +101,9 @@ jobs:
- name: Build Web artifact - name: Build Web artifact
run: npm run build run: npm run build
- name: Build Apple-signed and notarized Universal macOS artifacts - name: Build signed and notarized Universal macOS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
run: npm run desktop:build:macos-release -- --ci 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 - name: Verify and stage macOS artifacts
shell: bash shell: bash
run: | run: |
@@ -147,46 +111,28 @@ jobs:
artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)" 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)" dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then
echo "macOS app, updater artifact/signature, and DMG are all required." echo "Signed macOS app, updater artifact/signature, and DMG are all required."
exit 1 exit 1
fi fi
codesign --verify --deep --strict --verbose=2 "${app}" codesign --verify --deep --strict --verbose=2 "${app}"
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "signed" ]]; then
spctl --assess --type execute --verbose=2 "${app}" spctl --assess --type execute --verbose=2 "${app}"
xcrun stapler validate "${app}" xcrun stapler validate "${app}"
xcrun stapler validate "${dmg}" 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" stage="src-tauri/target/desktop-release-macos"
mkdir -p "${stage}" mkdir -p "${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}/" cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/"
fi
- name: Upload macOS candidate artifacts - name: Upload signed macOS candidate artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} name: ctms-desktop-macos-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-macos/* path: frontend/src-tauri/target/desktop-release-macos/*
if-no-files-found: error if-no-files-found: error
windows-release-candidate: windows-release-candidate:
name: Windows release candidate name: Signed Windows release candidate
needs: release-policy
runs-on: windows-latest runs-on: windows-latest
defaults: defaults:
run: run:
@@ -195,11 +141,7 @@ jobs:
VITE_BUILD_CHANNEL: release VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }} VITE_BUILD_COMMIT: ${{ github.sha }}
RELEASE_BUILD: "true" RELEASE_BUILD: "true"
DESKTOP_RELEASE_PLATFORM: windows REQUIRE_WINDOWS_SIGNING: "true"
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 }} 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: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -221,7 +163,7 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
if ($env:GITHUB_REF_TYPE -ne "tag") { if ($env:GITHUB_REF_TYPE -ne "tag") {
throw "Desktop release candidates must run from a vX.Y.Z tag." throw "Signed desktop release candidates must run from a vX.Y.Z tag."
} }
$expectedTag = "v$(node -p "require('./package.json').version")" $expectedTag = "v$(node -p "require('./package.json').version")"
if ($env:GITHUB_REF_NAME -ne $expectedTag) { if ($env:GITHUB_REF_NAME -ne $expectedTag) {
@@ -240,7 +182,7 @@ jobs:
- name: Check release build metadata and signing environment - name: Check release build metadata and signing environment
run: npm run release:env:check run: npm run release:env:check
- name: Check desktop release readiness - name: Check signed desktop release readiness
run: npm run desktop:release-readiness:check run: npm run desktop:release-readiness:check
- name: Check synchronized client version - name: Check synchronized client version
@@ -265,7 +207,6 @@ jobs:
run: npm run build run: npm run build
- name: Import Windows code-signing certificate - name: Import Windows code-signing certificate
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
shell: pwsh shell: pwsh
run: | run: |
$pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx" $pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx"
@@ -292,7 +233,6 @@ jobs:
Remove-Item $pfxPath -Force Remove-Item $pfxPath -Force
- name: Write signed Windows Tauri config - name: Write signed Windows Tauri config
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
shell: pwsh shell: pwsh
run: | run: |
$config = @{ $config = @{
@@ -309,16 +249,10 @@ jobs:
Get-Content "tauri.windows.release.conf.json" Get-Content "tauri.windows.release.conf.json"
- name: Build signed Windows NSIS artifacts - name: Build signed Windows NSIS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
timeout-minutes: 30 timeout-minutes: 30
run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci
- name: Build unsigned Windows NSIS artifacts - name: Verify Authenticode and stage Windows 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 shell: pwsh
run: | run: |
$bundleDir = "src-tauri/target/release/bundle/nsis" $bundleDir = "src-tauri/target/release/bundle/nsis"
@@ -336,27 +270,14 @@ jobs:
foreach ($executable in @($appExecutables + $installers)) { foreach ($executable in @($appExecutables + $installers)) {
$signature = Get-AuthenticodeSignature -FilePath $executable.FullName $signature = Get-AuthenticodeSignature -FilePath $executable.FullName
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "signed") {
if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) { 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)" 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" $stage = "src-tauri/target/desktop-release-windows"
New-Item -ItemType Directory -Path $stage -Force | Out-Null New-Item -ItemType Directory -Path $stage -Force | Out-Null
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 Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage
}
- name: Remove Windows signing certificate - name: Remove Windows signing certificate
if: always() if: always()
@@ -368,17 +289,16 @@ jobs:
Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue
Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue
- name: Upload Windows candidate artifacts - name: Upload signed Windows candidate artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} name: ctms-desktop-windows-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-windows/* path: frontend/src-tauri/target/desktop-release-windows/*
if-no-files-found: error if-no-files-found: error
aggregate-release-candidate: aggregate-release-candidate:
name: Verify combined desktop release directory name: Verify combined desktop release directory
needs: needs:
- release-policy
- macos-release-candidate - macos-release-candidate
- windows-release-candidate - windows-release-candidate
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -396,21 +316,18 @@ jobs:
with: with:
node-version: "22.13" node-version: "22.13"
- name: Download macOS candidate artifacts - name: Download signed macOS candidate artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} name: ctms-desktop-macos-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-input/macos path: frontend/src-tauri/target/desktop-release-input/macos
- name: Download Windows candidate artifacts - name: Download signed Windows candidate artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} name: ctms-desktop-windows-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-input/windows 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 - name: Create combined desktop update feed
shell: bash shell: bash
run: | run: |
@@ -423,32 +340,20 @@ jobs:
exit 1 exit 1
fi 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 -- \ npm run desktop:update-feed:create -- \
--artifact "${mac_artifacts[0]}" \ --artifact "${mac_artifacts[0]}" \
--platform-artifact "windows-x86_64=${windows_artifacts[0]}" \ --platform-artifact "windows-x86_64=${windows_artifacts[0]}" \
"${include_args[@]}" \ --include "${dmgs[0]}" \
"${notes_args[@]}" \ --include "${installers[0]}" \
--output-dir src-tauri/target/desktop-release-feed \ --output-dir src-tauri/target/desktop-release-feed \
--base-url "${DESKTOP_UPDATE_BASE_URL}" --base-url "${DESKTOP_UPDATE_BASE_URL}"
- name: Verify combined desktop update feed - 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 --require-provenance 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
- name: Upload verified desktop release directory - name: Upload verified desktop release directory
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ctms-desktop-release-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }} name: ctms-desktop-release-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-feed/* path: frontend/src-tauri/target/desktop-release-feed/*
if-no-files-found: error if-no-files-found: error
+2 -2
View File
@@ -14,7 +14,7 @@
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。 - 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs``npm run runtime:check` 和桌面发布检查清单是否需要更新。 - 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs``npm run runtime:check` 和桌面发布检查清单是否需要更新。
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。 - token、附件下载凭据和敏感业务信息不得写入 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 或正式发布制品。 - 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 或正式发布制品。
## 分支与发布治理 ## 分支与发布治理
@@ -57,4 +57,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 中验证缓存清理和诊断入口。 本地缓存相关变更至少执行 `runtime:check``desktop:release:check``ui:contract``type-check``test:unit``build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、发布说明、校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。该例外不自动适用于后续版本 正式桌面发布构建必须使用组织批准的 updater 签名私钥macOS 必须完成 Apple 签名/公证Windows 必须完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。未签名或 ad-hoc 构建只能作为内部验证构建描述
@@ -28,14 +28,14 @@ npm run desktop:build:app
- [ ] `frontend/package.json``package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。 - [ ] `frontend/package.json``package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
- [ ] `VITE_BUILD_CHANNEL=release``VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。 - [ ] `VITE_BUILD_CHANNEL=release``VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
- [ ] 在正式 release tag 环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、当前精确版本的平台签名策略、updater 私钥和生产 artifact HTTPS 基址齐备。 - [ ] 在正式 release tag 和签名环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址齐备。
- [ ] macOS app 已完成 Apple 签名/公证;若当前精确版本获批平台签名例外,则已验证 `Signature=adhoc`、确认未公证并附带 Gatekeeper 风险说明 - [ ] macOS app 已签名公证。
- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 返回 `Valid`;若当前精确版本获批例外,则应用和安装器必须返回 `NotSigned` 并附带 SmartScreen 风险说明 - [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 应用和安装器返回 `Valid`
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。 - [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
- [ ] 始终设置 `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` - [ ] 设置 `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``REQUIRE_WINDOWS_SIGNING=true`;例外路径不得伪装签名状态,构建后必须对应用和安装器验证 `NotSigned` - [ ] 设置 Windows PFX、密码、`WINDOWS_TIMESTAMP_URL` updater 私钥后,以 `REQUIRE_WINDOWS_SIGNING=true` 执行 readiness,再执行 `npm run desktop:build:windows-release -- --ci`
- [ ] 正式 updater feed 使用 `--artifact <CTMS.app.tar.gz>``--platform-artifact windows-x86_64=<CTMS.nsis.zip>` 汇总生成同一个 `latest.json``SHA256SUMS.txt` - [ ] 正式 updater feed 使用 `--artifact <CTMS.app.tar.gz>``--platform-artifact windows-x86_64=<CTMS.nsis.zip>` 汇总生成同一个 `latest.json``SHA256SUMS.txt`
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`,并确认两个平台的 updater artifact、`.sig`、安装包、provenance、checksum manifest 和 `latest.json` 均通过校验。 - [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64`,并确认两个平台的 updater artifact、`.sig`、安装包、checksum manifest 和 `latest.json` 均通过校验。
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json` - [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。 - [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
@@ -70,7 +70,7 @@ npm run desktop:build:app
- [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。 - [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。
- [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。 - [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。 - [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
- [ ] 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。 - [ ] 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。
人工复审还必须确认: 人工复审还必须确认:
@@ -276,19 +276,6 @@ npm run desktop:build:app
创建正式 tag 前仍必须由发布负责人确认: 创建正式 tag 前仍必须由发布负责人确认:
- GitHub Actions 已配置 updater secrets versioned artifact base URL;默认平台签名路径还必须配置 Apple 与 Windows secrets 和 Windows timestamp URL。 - GitHub Actions 已配置 updater、Apple 与 Windows 签名 secrets,以及 versioned artifact base URL 和 Windows timestamp URL。
- 若使用默认 Windows 签名路径,组织证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。 - 组织 Windows 证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。
- macOS/Windows 原生 candidate job、联合 feed 校验、真实安装/升级和生产下载源上传全部通过。 - 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 校验。
- 制品与 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 上验证。
-9
View File
@@ -305,18 +305,9 @@ Before promoting `main` to `release`, confirm:
- release checklist is complete - release checklist is complete
- production initialization, build, and smoke verification are 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 - rollback or remediation path is clear
- release tag has been prepared - 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.
Reference: Reference:
- [docs/guides/release-checklist.md](guides/release-checklist.md) - [docs/guides/release-checklist.md](guides/release-checklist.md)
+3 -4
View File
@@ -106,7 +106,7 @@ npm run desktop:build:app
npm run desktop:build:app npm run desktop:build:app
``` ```
正式桌面发布构建必须从同一正式 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。 正式桌面发布构建必须从同一正式 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。
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。 后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
@@ -206,7 +206,7 @@ dev -> main
- 前后端测试通过 - 前后端测试通过
- 网页端构建通过 - 网页端构建通过
- macOS 桌面端构建通过 - macOS 桌面端构建通过
- Windows x64 NSIS 兼容性构建通过;正式发布时平台签名和时间戳验证通过,或当前精确版本的已批准平台签名例外验证通过 - Windows x64 NSIS 兼容性构建通过;正式发布时签名和时间戳验证通过
- 数据库迁移经过验证 - 数据库迁移经过验证
- 已知风险和回滚方式已记录 - 已知风险和回滚方式已记录
@@ -295,7 +295,7 @@ git tag -a v1.2.0 -m "CTMS v1.2.0"
git push origin v1.2.0 git push origin v1.2.0
``` ```
网页端、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` 网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。macOS 签名/公证Windows 代码签名/RFC 3161 时间戳以及两个平台的 updater 签名制品必须全部验证通过后,才能分发安装包并最后原子替换生产 `latest.json`
发布记录至少包含: 发布记录至少包含:
@@ -450,7 +450,6 @@ git log --oneline --decorate --graph --all -30
- [ ] `npm run build` 通过 - [ ] `npm run build` 通过
- [ ] `npm run desktop:build:app` 通过 - [ ] `npm run desktop:build:app` 通过
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行 - [ ] 正式桌面发布构建已使用 updater 签名私钥执行
- [ ] 平台签名已验证,或当前精确版本已在 `frontend/desktop-release-policy.json` 获批例外且 `UNSIGNED-PLATFORM` 风险说明、provenance 和 checksum 齐备
- [ ] 数据库迁移与回滚方案确认 - [ ] 数据库迁移与回滚方案确认
- [ ] 发布说明完成 - [ ] 发布说明完成
- [ ] `main -> release` 合并完成 - [ ] `main -> release` 合并完成
+24 -54
View File
@@ -87,11 +87,7 @@ npm run desktop:build:app
`release:env:check` verifies build channel and commit metadata, and becomes `release:env:check` verifies build channel and commit metadata, and becomes
platform-strict with `REQUIRE_DESKTOP_SIGNING=true` on macOS or platform-strict with `REQUIRE_DESKTOP_SIGNING=true` on macOS or
`REQUIRE_WINDOWS_SIGNING=true` on Windows. Formal native jobs also set `REQUIRE_WINDOWS_SIGNING=true` on Windows.
`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 `desktop:release:check` statically verifies the Tauri bundle, updater public
key, CSP, capability scopes, command allowlist, query-token ban, generic system key, CSP, capability scopes, command allowlist, query-token ban, generic system
notification boundary, CI gate coverage, and secure session token boundary. The notification boundary, CI gate coverage, and secure session token boundary. The
@@ -136,10 +132,10 @@ The release pipeline must:
1. build from the accepted release tag and commit; 1. build from the accepted release tag and commit;
2. build the Universal macOS app/DMG and Windows x64 NSIS installer from that tag; 2. build the Universal macOS app/DMG and Windows x64 NSIS installer from that tag;
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; 3. sign and notarize macOS, and Authenticode-sign Windows with an RFC 3161 timestamp;
4. produce macOS `.app.tar.gz` and Windows `.nsis.zip` updater artifacts plus their `.sig` files with the shared updater private key; 4. produce macOS `.app.tar.gz` and Windows `.nsis.zip` updater artifacts plus their `.sig` files with the shared updater private key;
5. generate `DESKTOP-RELEASE-PROVENANCE.json`, one combined `latest.json`, and a checksum manifest; 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 <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`; 6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64`;
7. upload immutable artifacts first; 7. upload immutable artifacts first;
8. atomically replace `latest.json` last. 8. atomically replace `latest.json` last.
@@ -147,33 +143,22 @@ For Universal macOS artifacts, `latest.json` must provide both
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal `darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
update package. update package.
The same feed must also contain `windows-x86_64`, pointing to the updater-signed The same feed must also contain `windows-x86_64`, pointing to the signed NSIS
NSIS `.nsis.zip` package. The Windows `.exe` installer is distributed next to `.nsis.zip` updater package. The Windows `.exe` installer is distributed next
the updater package but is not used as the updater URL. Tauri updater signing is to the updater package but is not used as the updater URL.
mandatory in both platform-signed and platform-signing-exception modes.
The formal release candidate workflow lives at The signed release candidate workflow lives at
`.github/workflows/desktop-release-candidate.yml`. It must be run from a `.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 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 artifact. That artifact is still only a release candidate; the release owner
must upload immutable files to the production download origin and replace must upload immutable files to the production download origin and replace
`latest.json` atomically after validation. `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.
## Windows Release and Internal Validation ## Windows Release and Internal Validation
Windows x64 NSIS is an approved formal Desktop target. Formal Windows builds Windows x64 NSIS is an approved formal Desktop target. Formal Windows builds
run in `.github/workflows/desktop-release-candidate.yml` from the same exact run in `.github/workflows/desktop-release-candidate.yml` from the same exact
`vX.Y.Z` tag and SHA as Web and macOS. The default signed path must: `vX.Y.Z` tag and SHA as Web and macOS. They must:
- import a Base64-encoded PFX from `WINDOWS_CERTIFICATE` using - import a Base64-encoded PFX from `WINDOWS_CERTIFICATE` using
`WINDOWS_CERTIFICATE_PASSWORD`; `WINDOWS_CERTIFICATE_PASSWORD`;
@@ -185,12 +170,6 @@ 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 - publish the signed NSIS `.exe`, `.nsis.zip`, and `.nsis.zip.sig` into the
combined verified Desktop release directory. 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: Windows release validation also covers:
- WebView2 runtime prerequisite behavior; - WebView2 runtime prerequisite behavior;
@@ -207,23 +186,19 @@ only, runs on `windows-latest`, injects `VITE_BUILD_CHANNEL` and
and Desktop safety gates, builds an unsigned NSIS installer with updater and Desktop safety gates, builds an unsigned NSIS installer with updater
artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub
Actions artifact. This workflow is for internal compatibility verification Actions artifact. This workflow is for internal compatibility verification
only; it must not generate `latest.json`, update feeds, verified release only; it must not generate `latest.json`, update feeds, signed release
directories, or formal Windows release artifacts. A successful internal build directories, or formal Windows release artifacts. A successful internal build
does not substitute for the tag-only formal workflow, including when that does not substitute for the signed tag-only release workflow.
formal workflow uses an approved platform-signing exception.
The formal workflow always requires these organization settings: The formal workflow requires these organization settings:
- secrets: `TAURI_SIGNING_PRIVATE_KEY` and - secrets: `TAURI_SIGNING_PRIVATE_KEY`,
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`; `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, `WINDOWS_CERTIFICATE`, and
`WINDOWS_CERTIFICATE_PASSWORD`;
- variable: `WINDOWS_TIMESTAMP_URL`;
- shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the - shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the
manual workflow input. manual workflow input.
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 The checked-in workflow implements the exportable PFX path. Confirm that the
organization's certificate policy permits an exportable CI certificate before organization's certificate policy permits an exportable CI certificate before
provisioning it. If the selected CA provides only hardware- or cloud-backed provisioning it. If the selected CA provides only hardware- or cloud-backed
@@ -249,21 +224,16 @@ npm run build
npm run desktop:build:app npm run desktop:build:app
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY" export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD" export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
export REQUIRE_UPDATER_SIGNING=true export REQUIRE_DESKTOP_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 release:env:check
npm run desktop:release-readiness:check npm run desktop:release-readiness:check
npm run desktop:build:macos-unsigned-release -- --ci npm run desktop:build:macos-release -- --ci
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --platform-artifact windows-x86_64=<CTMS.nsis.zip> --include <CTMS.dmg> --include <CTMS-installer.exe> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir> npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --platform-artifact windows-x86_64=<CTMS.nsis.zip> --include <CTMS.dmg> --include <CTMS-installer.exe> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64
``` ```
The macOS and Windows builds run in their native CI jobs and always use the same The macOS and Windows signed builds run in their native CI jobs. macOS requires
updater signing key. In the default path, macOS requires Apple the Apple signing/notarization credentials defined by the release owner;
signing/notarization credentials and Windows requires the PFX/password and Windows requires the PFX certificate/password and timestamp URL above. Both use
timestamp URL. In the approved v0.1.0 exception, macOS must verify the same updater signing key and are aggregated only after both native jobs
`Signature=adhoc`, Windows must verify `NotSigned`, and both must carry explicit pass. Unsigned internal builds are not formal distributions.
platform-trust warnings. Native artifacts are aggregated only after both jobs
and the combined updater feed pass.
-16
View File
@@ -1,16 +0,0 @@
{
"schemaVersion": 1,
"defaultPlatformSigningMode": "signed",
"updaterSigningRequired": true,
"unsignedPlatformExceptions": [
{
"version": "0.1.0",
"status": "approved",
"macos": "ad-hoc",
"windows": "unsigned",
"distribution": "controlled",
"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."
}
]
}
@@ -1,11 +0,0 @@
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.
- 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.
-3
View File
@@ -15,10 +15,7 @@
"desktop:build": "tauri build", "desktop:build": "tauri build",
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app", "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-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: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:update-feed:create": "node scripts/create-desktop-update-feed.mjs",
"desktop:bundle:dmg": "tauri build --bundles dmg", "desktop:bundle:dmg": "tauri build --bundles dmg",
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs", "desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
@@ -1,64 +0,0 @@
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 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}.`);
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,
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}`);
@@ -1,86 +0,0 @@
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 || "<missing>"}`;
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.`);
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 || "<missing>"}.`);
}
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,
exception,
};
};
@@ -1,47 +0,0 @@
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 || "<missing>"} ${process.env.GITHUB_REF_NAME || "<missing>"}.`,
);
}
}
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");
@@ -2,13 +2,10 @@ import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, ".."); const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = []; const failures = [];
const env = process.env; const env = process.env;
@@ -16,10 +13,6 @@ const fullShaPattern = /^[0-9a-f]{40}$/i;
const expectedTag = `v${packageInfo.version}`; const expectedTag = `v${packageInfo.version}`;
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true"; const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
const requiresWindowsSigning = env.REQUIRE_WINDOWS_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 requiredUpdaterEnv = ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"];
const requiredMacosEnv = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID"]; const requiredMacosEnv = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID"];
const requiredWindowsEnv = ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD"]; const requiredWindowsEnv = ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD"];
@@ -45,7 +38,7 @@ const gitMaybe = (args) => {
}; };
const requireEnv = (name) => { const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for desktop release readiness.`); assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
}; };
const validateBaseUrl = () => { const validateBaseUrl = () => {
@@ -102,54 +95,19 @@ if (headSha && env.VITE_BUILD_COMMIT) {
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release 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( assert(
!(requiresMacosSigning && requiresWindowsSigning), !(requiresMacosSigning && requiresWindowsSigning),
"macOS and Windows signing readiness must be checked in their native jobs.", "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) { for (const name of requiredUpdaterEnv) {
requireEnv(name); 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) { if (requiresMacosSigning) {
assert(process.platform === "darwin", "Signed macOS desktop release readiness must run on macOS."); assert(process.platform === "darwin", "Signed macOS desktop release readiness must run on macOS.");
for (const name of requiredMacosEnv) { for (const name of requiredMacosEnv) {
+1 -34
View File
@@ -62,7 +62,7 @@ const verifyTauriConfig = async () => {
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution."); assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
assert( assert(
tauriConfig.bundle?.createUpdaterArtifacts === true, tauriConfig.bundle?.createUpdaterArtifacts === true,
"Tauri must create signed updater artifacts for formal desktop release builds.", "Tauri must create updater artifacts for signed desktop release builds.",
); );
assert( assert(
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80, typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
@@ -392,10 +392,7 @@ const verifyWorkflowGates = async () => {
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0."); assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
const requiredScripts = [ const requiredScripts = [
"desktop:build:macos-release", "desktop:build:macos-release",
"desktop:build:macos-unsigned-release",
"desktop:build:windows-release", "desktop:build:windows-release",
"desktop:release-policy:check",
"desktop:release-provenance:create",
"desktop:update-feed:create", "desktop:update-feed:create",
"desktop:update-feed:check", "desktop:update-feed:check",
"desktop:release-readiness:check", "desktop:release-readiness:check",
@@ -406,26 +403,6 @@ const verifyWorkflowGates = async () => {
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`); assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
} }
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.");
}
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 workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [ const requiredCommands = [
"npm run version:check", "npm run version:check",
@@ -452,16 +429,6 @@ const verifyWorkflowGates = async () => {
"runs-on: windows-latest", "runs-on: windows-latest",
"REQUIRE_DESKTOP_SIGNING", "REQUIRE_DESKTOP_SIGNING",
"REQUIRE_WINDOWS_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", "TAURI_SIGNING_PRIVATE_KEY",
"APPLE_ID", "APPLE_ID",
"APPLE_PASSWORD", "APPLE_PASSWORD",
@@ -2,12 +2,9 @@ import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { basename, resolve } from "node:path"; import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = []; const failures = [];
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -24,11 +21,6 @@ const feedPath = resolve(
); );
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR; const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL; 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([ const requiredPlatforms = new Set([
"darwin-aarch64", "darwin-aarch64",
"darwin-x86_64", "darwin-x86_64",
@@ -102,56 +94,6 @@ 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; let feed;
try { try {
feed = JSON.parse(await readFile(feedPath, "utf8")); feed = JSON.parse(await readFile(feedPath, "utf8"));
@@ -162,7 +104,6 @@ try {
if (feed) { if (feed) {
const checksums = await readChecksumManifest(); const checksums = await readChecksumManifest();
await assertManifestEntries(checksums); await assertManifestEntries(checksums);
await verifyProvenance(checksums);
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, ""); const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {}; const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"]; const darwinArm = platforms["darwin-aarch64"];
+11 -66
View File
@@ -2,13 +2,10 @@ import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, ".."); const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const desktopReleasePolicy = await loadDesktopReleasePolicy();
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
const failures = []; const failures = [];
const allowedChannels = new Set(["dev", "main", "release", "local"]); const allowedChannels = new Set(["dev", "main", "release", "local"]);
@@ -23,17 +20,6 @@ const isTagBuild = env.GITHUB_REF_TYPE === "tag";
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release"; const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true"; const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
const requiresWindowsSigning = env.REQUIRE_WINDOWS_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 fail = (message) => failures.push(message); const fail = (message) => failures.push(message);
const assert = (condition, message) => { const assert = (condition, message) => {
@@ -41,7 +27,7 @@ const assert = (condition, message) => {
}; };
const requireEnv = (name) => { const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for this desktop release build.`); assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
}; };
const gitHead = () => { const gitHead = () => {
@@ -88,59 +74,13 @@ assert(
"macOS and Windows signing requirements must be checked in their native jobs.", "macOS and Windows signing requirements must be checked in their native jobs.",
); );
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, "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) { if (requiresMacosSigning) {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS."); 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"); requireEnv("APPLE_ID");
requireEnv("APPLE_PASSWORD"); requireEnv("APPLE_PASSWORD");
requireEnv("APPLE_TEAM_ID"); requireEnv("APPLE_TEAM_ID");
@@ -155,6 +95,11 @@ if (requiresMacosSigning) {
if (requiresWindowsSigning) { if (requiresWindowsSigning) {
assert(process.platform === "win32", "Signed Windows desktop release builds must run on Windows."); 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");
requireEnv("WINDOWS_CERTIFICATE_PASSWORD"); requireEnv("WINDOWS_CERTIFICATE_PASSWORD");
requireEnv("WINDOWS_TIMESTAMP_URL"); requireEnv("WINDOWS_TIMESTAMP_URL");