build(release): stage local macOS v0.1.0 distribution
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 均验证通过后才能激活自动更新源。该本地/分阶段例外不适用于后续版本。
|
||||
|
||||
## 分支与发布治理
|
||||
|
||||
|
||||
@@ -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 签名。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
发布记录至少包含:
|
||||
|
||||
- 产品版本号
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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")}`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+1
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2,6 +2,7 @@ export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./c
|
||||
export {
|
||||
clearDesktopServerUrl,
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
getDefaultDesktopServerUrl,
|
||||
getDesktopServerUrl,
|
||||
hasDesktopServerUrl,
|
||||
normalizeDesktopServerUrl,
|
||||
|
||||
Reference in New Issue
Block a user