完善桌面端界面、发布检查与邮箱域名同步
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-01 10:49:31 +08:00
parent c923f887a0
commit 9cac75e85c
32 changed files with 5392 additions and 2684 deletions
@@ -48,6 +48,7 @@ const verifyTauriConfig = async () => {
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
const csp = tauriConfig.app?.security?.csp || "";
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
const mainWindow = tauriConfig.app?.windows?.find((window) => window.label === "main") || tauriConfig.app?.windows?.[0];
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
@@ -68,6 +69,8 @@ const verifyTauriConfig = async () => {
"Tauri CSP must not use wildcard sources.",
);
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
};
const verifyCapabilities = async () => {
@@ -151,16 +154,61 @@ const verifySourceSafety = async () => {
const source = await readFile(path, "utf8");
const file = relative(rootDir, path);
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
assert(
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
`${file}: token-related values must not be written to console logs.`,
);
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
}
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
}
}
};
const verifyNotificationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
};
const verifyUpdaterBoundary = async () => {
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
assert(source.includes("desktop updates require HTTPS outside localhost"), "Desktop updater must reject non-local HTTP update feeds.");
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
};
const verifyWorkflowGates = async () => {
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [
"npm run version:check",
"npm run runtime:check",
"npm run desktop:release:check",
"npm run ui:contract",
"npm run type-check",
"npm run test:unit",
"npm run build",
"npm run desktop:build:app",
"npm run release:env:check",
];
for (const command of requiredCommands) {
assert(workflow.includes(command), `Client quality gates workflow must run ${command}.`);
}
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.");
};
await verifyTauriConfig();
await verifyCapabilities();
await verifyRustBoundary();
await verifySourceSafety();
await verifyNotificationBoundary();
await verifyUpdaterBoundary();
await verifyWorkflowGates();
if (failures.length > 0) {
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
@@ -0,0 +1,98 @@
import { access, readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const args = process.argv.slice(2);
const optionValue = (name) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
};
const feedPath = resolve(
frontendDir,
optionValue("--feed") || process.env.DESKTOP_UPDATE_FEED || "src-tauri/target/release/bundle/latest.json",
);
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const assertFileExists = async (path, description) => {
try {
await access(path);
} catch {
fail(`${description} does not exist: ${path}`);
}
};
let feed;
try {
feed = JSON.parse(await readFile(feedPath, "utf8"));
} catch (error) {
fail(`Cannot read desktop update feed ${feedPath}: ${error.message}`);
}
if (feed) {
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"];
const darwinIntel = platforms["darwin-x86_64"];
assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`);
assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date.");
assert(Boolean(darwinArm), "latest.json must include darwin-aarch64.");
assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64.");
const entries = [
["darwin-aarch64", darwinArm],
["darwin-x86_64", darwinIntel],
];
for (const [platform, entry] of entries) {
const rawUrl = entry?.url;
const signature = entry?.signature;
assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`);
assert(typeof rawUrl === "string" && rawUrl.length > 0, `${platform} must include an artifact URL.`);
if (!rawUrl) continue;
let url;
try {
url = new URL(rawUrl);
} catch {
fail(`${platform} artifact URL is invalid: ${rawUrl}`);
continue;
}
assert(url.protocol === "https:", `${platform} artifact URL must use HTTPS.`);
assert(!/[?&]token=/i.test(url.search), `${platform} artifact URL must not contain token query parameters.`);
assert(!url.pathname.endsWith("/latest.json"), `${platform} artifact URL must point to an immutable artifact, not latest.json.`);
assert(url.pathname.includes(packageInfo.version), `${platform} artifact URL must include version ${packageInfo.version}.`);
if (expectedBaseUrl) {
assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`);
}
if (artifactDir) {
const artifactPath = resolve(artifactDir, basename(url.pathname));
await assertFileExists(artifactPath, `${platform} updater artifact`);
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
}
}
if (darwinArm?.url && darwinIntel?.url) {
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
}
}
if (failures.length > 0) {
console.error(`Desktop update feed check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop update feed check passed.");
}
@@ -0,0 +1,66 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const allowedChannels = new Set(["dev", "main", "release", "local"]);
const fullShaPattern = /^[0-9a-f]{40}$/i;
const semverTag = `v${packageInfo.version}`;
const env = process.env;
const channel = env.VITE_BUILD_CHANNEL || "local";
const commit = env.VITE_BUILD_COMMIT || "local";
const isCi = env.CI === "true" || env.GITHUB_ACTIONS === "true";
const isTagBuild = env.GITHUB_REF_TYPE === "tag";
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
};
assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...allowedChannels].join(", ")}.`);
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.");
}
if (env.GITHUB_SHA) {
assert(
commit === env.GITHUB_SHA || commit === "local",
"VITE_BUILD_COMMIT must match GITHUB_SHA when GitHub Actions provides a source commit.",
);
}
if (isTagBuild) {
assert(env.GITHUB_REF_NAME === semverTag, `Release tag must be ${semverTag}; found ${env.GITHUB_REF_NAME || "<missing>"}.`);
assert(channel === "release", "Release tag builds must set VITE_BUILD_CHANNEL=release.");
}
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
requireEnv("APPLE_ID");
requireEnv("APPLE_PASSWORD");
requireEnv("APPLE_TEAM_ID");
assert(
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
}
if (failures.length > 0) {
console.error(`Release build environment check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Release build environment check passed.");
}