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 cd0a4565..59c301bb 100644 --- a/.github/workflows/desktop-release-candidate.yml +++ b/.github/workflows/desktop-release-candidate.yml @@ -55,6 +55,7 @@ jobs: env: VITE_BUILD_CHANNEL: release VITE_BUILD_COMMIT: ${{ github.sha }} + VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }} RELEASE_BUILD: "true" DESKTOP_RELEASE_PLATFORM: macos DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }} @@ -194,6 +195,7 @@ jobs: env: VITE_BUILD_CHANNEL: release VITE_BUILD_COMMIT: ${{ github.sha }} + VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }} RELEASE_BUILD: "true" DESKTOP_RELEASE_PLATFORM: windows DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }} 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 14f004ec..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、日志、系统通知正文或明文浏览器存储。 +- 正式桌面客户端的默认 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 均验证通过后才能激活自动更新源。该本地/分阶段例外不适用于后续版本。 ## 分支与发布治理 diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 2fb8e603..0c790457 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -88,7 +88,8 @@ npm run desktop:build:app | 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 | | 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 | | 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 | -| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | +| 构建默认服务器地址已注入 | 不适用 | 必测 | 首次启动使用 `VITE_DESKTOP_SERVER_URL`,不要求重复输入;运行时代码不含生产域名 | +| 构建默认地址和本地配置均缺失 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | | 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | | 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 | | 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 | @@ -288,6 +289,9 @@ npm run desktop:build:app - 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 签名。 diff --git a/docs/branch-governance.md b/docs/branch-governance.md index 5cc7efa8..99471e7c 100644 --- a/docs/branch-governance.md +++ b/docs/branch-governance.md @@ -317,6 +317,15 @@ 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 9835317c..dad6fabb 100644 --- a/docs/guides/branch-maintenance-sop-zh.md +++ b/docs/guides/branch-maintenance-sop-zh.md @@ -108,6 +108,8 @@ 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。 +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` 实物验证完成。 + 后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。 ### 4. 创建提交 @@ -297,6 +299,8 @@ 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`。 +若执行 v0.1.0 的已批准分阶段应急路径,首次 macOS Release 可先于 Windows 发布,但 tag/SHA 不得移动;首次 Release 不包含 `latest.json`,并必须明确标记 Windows pending。Windows 后补且联合 feed 验证通过后,才允许按上述顺序激活生产 updater feed。 + 发布记录至少包含: - 产品版本号 diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md index ca239810..b854e2ec 100644 --- a/docs/guides/client-release.md +++ b/docs/guides/client-release.md @@ -169,6 +169,21 @@ and release directory contain `UNSIGNED-PLATFORM`; the directory must include 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 @@ -216,9 +231,19 @@ The formal workflow always requires these organization settings: - 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 @@ -239,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 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 index 71dbdbbb..964cfb12 100644 --- a/frontend/desktop-release-policy.json +++ b/frontend/desktop-release-policy.json @@ -9,8 +9,11 @@ "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." + "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 index 8e01376f..6148b6dd 100644 --- a/frontend/desktop-release-unsigned-warning.txt +++ b/frontend/desktop-release-unsigned-warning.txt @@ -6,6 +6,7 @@ This release is distributed under the version-scoped v0.1.0 platform-signing exc - 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/scripts/create-desktop-release-provenance.mjs b/frontend/scripts/create-desktop-release-provenance.mjs index f9d4a436..a6213975 100644 --- a/frontend/scripts/create-desktop-release-provenance.mjs +++ b/frontend/scripts/create-desktop-release-provenance.mjs @@ -17,6 +17,7 @@ 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", @@ -31,6 +32,13 @@ assert(tag === `v${packageInfo.version}`, `Release provenance tag must be v${pac 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")}`); @@ -50,6 +58,13 @@ const provenance = { 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.", diff --git a/frontend/scripts/desktop-release-policy.mjs b/frontend/scripts/desktop-release-policy.mjs index 5d340dff..7d364b14 100644 --- a/frontend/scripts/desktop-release-policy.mjs +++ b/frontend/scripts/desktop-release-policy.mjs @@ -34,6 +34,14 @@ const validatePolicy = (policy) => { 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, @@ -81,6 +89,9 @@ export const resolveDesktopReleasePolicy = (version, policy) => { artifactLabel: "UNSIGNED-PLATFORM", distribution: exception.distribution, warningRequired: true, + macosLocalExactTagFallback: exception.macosLocalExactTagFallback === true, + windowsDelivery: exception.windowsDelivery, + updaterFeedActivation: exception.updaterFeedActivation, exception, }; }; diff --git a/frontend/scripts/verify-desktop-release-readiness.mjs b/frontend/scripts/verify-desktop-release-readiness.mjs index 891f577b..b699852d 100644 --- a/frontend/scripts/verify-desktop-release-readiness.mjs +++ b/frontend/scripts/verify-desktop-release-readiness.mjs @@ -70,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"); @@ -173,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 cd9c1a0c..019b3f2b 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -387,6 +387,19 @@ 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."); @@ -406,6 +419,18 @@ 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( @@ -418,6 +443,12 @@ const verifyWorkflowGates = async () => { ); 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( @@ -444,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"); @@ -463,6 +495,7 @@ const verifyWorkflowGates = async () => { "DESKTOP-RELEASE-PROVENANCE.json", "--require-provenance", "TAURI_SIGNING_PRIVATE_KEY", + "VITE_DESKTOP_SERVER_URL", "APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID", @@ -496,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", @@ -554,6 +588,7 @@ await verifySourceSafety(); await verifyNotificationBoundary(); await verifySessionBoundary(); await verifyUpdaterBoundary(); +await verifyDesktopServerConfigurationBoundary(); await verifyOnlyOfficeBoundary(); await verifyWorkflowGates(); diff --git a/frontend/scripts/verify-release-build-env.mjs b/frontend/scripts/verify-release-build-env.mjs index d66337f8..e1206851 100644 --- a/frontend/scripts/verify-release-build-env.mjs +++ b/frontend/scripts/verify-release-build-env.mjs @@ -35,6 +35,25 @@ const isNativeDesktopRelease = Boolean( 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) => { if (!condition) fail(message); @@ -61,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) { 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,