build(release): stage local macOS v0.1.0 distribution
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-17 11:31:11 +08:00
parent c23a5d6a22
commit e3489f3b06
21 changed files with 211 additions and 4 deletions
+2
View File
@@ -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
+4 -1
View File
@@ -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) {
+1
View File
@@ -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);
});
});
+11 -2
View File
@@ -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();
+1
View File
@@ -2,6 +2,7 @@ export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./c
export {
clearDesktopServerUrl,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDefaultDesktopServerUrl,
getDesktopServerUrl,
hasDesktopServerUrl,
normalizeDesktopServerUrl,