完善桌面端发布稳定化门禁
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
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 values = (name) => {
|
||||
const found = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] === name && args[index + 1]) {
|
||||
found.push(args[index + 1]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
const value = (name) => values(name)[0];
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
|
||||
const outputDir = resolve(
|
||||
frontendDir,
|
||||
value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed",
|
||||
);
|
||||
const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
||||
const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString();
|
||||
const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES;
|
||||
const includes = values("--include").map((path) => resolve(frontendDir, path));
|
||||
|
||||
assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required.");
|
||||
assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required.");
|
||||
|
||||
const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined;
|
||||
const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined;
|
||||
const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined;
|
||||
const uploadFiles = [];
|
||||
|
||||
const readRequiredFile = async (path, description) => {
|
||||
try {
|
||||
return await readFile(path);
|
||||
} catch (error) {
|
||||
fail(`${description} cannot be read: ${path} (${error.message})`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const sha256 = async (path) => {
|
||||
const data = await readFile(path);
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
};
|
||||
|
||||
const normalizedBaseUrl = () => {
|
||||
if (!baseUrlRaw) return undefined;
|
||||
try {
|
||||
const url = new URL(baseUrlRaw.endsWith("/") ? baseUrlRaw : `${baseUrlRaw}/`);
|
||||
assert(url.protocol === "https:", "Desktop update artifact base URL must use HTTPS.");
|
||||
assert(url.username === "" && url.password === "", "Desktop update artifact base URL must not include credentials.");
|
||||
assert(!/[?&]token=/i.test(url.search), "Desktop update artifact base URL must not include token query parameters.");
|
||||
assert(url.pathname.includes(packageInfo.version), `Desktop update artifact base URL must include version ${packageInfo.version}.`);
|
||||
return url;
|
||||
} catch (error) {
|
||||
fail(`Desktop update artifact base URL is invalid: ${baseUrlRaw} (${error.message})`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const copyIntoOutput = async (sourcePath) => {
|
||||
const destination = resolve(outputDir, basename(sourcePath));
|
||||
if (sourcePath !== destination) {
|
||||
await copyFile(sourcePath, destination);
|
||||
}
|
||||
return destination;
|
||||
};
|
||||
|
||||
if (resolvedArtifactPath && signaturePath) {
|
||||
await readRequiredFile(resolvedArtifactPath, "Updater artifact");
|
||||
const signature = await readRequiredFile(signaturePath, "Updater artifact signature");
|
||||
if (signature) {
|
||||
const signatureText = signature.toString("utf8").trim();
|
||||
assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short.");
|
||||
}
|
||||
}
|
||||
|
||||
for (const includePath of includes) {
|
||||
try {
|
||||
const metadata = await stat(includePath);
|
||||
assert(metadata.isFile(), `Included release file must be a file: ${includePath}`);
|
||||
} catch (error) {
|
||||
fail(`Included release file cannot be read: ${includePath} (${error.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = normalizedBaseUrl();
|
||||
|
||||
if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const artifactUrl = new URL(artifactName, baseUrl).toString();
|
||||
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
|
||||
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
|
||||
|
||||
const signature = (await readFile(signaturePath, "utf8")).trim();
|
||||
const latest = {
|
||||
version: packageInfo.version,
|
||||
pub_date: pubDate,
|
||||
platforms: {
|
||||
"darwin-aarch64": {
|
||||
signature,
|
||||
url: artifactUrl,
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
signature,
|
||||
url: artifactUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (notes) {
|
||||
latest.notes = notes;
|
||||
}
|
||||
|
||||
uploadFiles.push(await copyIntoOutput(resolvedArtifactPath));
|
||||
uploadFiles.push(await copyIntoOutput(signaturePath));
|
||||
for (const includePath of includes) {
|
||||
uploadFiles.push(await copyIntoOutput(includePath));
|
||||
}
|
||||
|
||||
const latestPath = resolve(outputDir, "latest.json");
|
||||
await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`);
|
||||
uploadFiles.push(latestPath);
|
||||
|
||||
const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()];
|
||||
const checksumLines = [];
|
||||
for (const filePath of uniqueFiles) {
|
||||
checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`);
|
||||
}
|
||||
const checksumPath = resolve(outputDir, "SHA256SUMS.txt");
|
||||
await writeFile(checksumPath, `${checksumLines.join("\n")}\n`);
|
||||
|
||||
console.log(`Desktop update feed created in ${outputDir}`);
|
||||
console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`);
|
||||
console.log(` - SHA256SUMS.txt`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop update feed creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
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 rootDir = resolve(frontendDir, "..");
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const failures = [];
|
||||
|
||||
const env = process.env;
|
||||
const fullShaPattern = /^[0-9a-f]{40}$/i;
|
||||
const expectedTag = `v${packageInfo.version}`;
|
||||
const requiredSecretLikeEnv = [
|
||||
"TAURI_SIGNING_PRIVATE_KEY",
|
||||
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
|
||||
"APPLE_ID",
|
||||
"APPLE_PASSWORD",
|
||||
"APPLE_TEAM_ID",
|
||||
];
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const git = (args) =>
|
||||
execFileSync("git", args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
|
||||
const gitMaybe = (args) => {
|
||||
try {
|
||||
return git(args);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const requireEnv = (name) => {
|
||||
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
|
||||
};
|
||||
|
||||
const validateBaseUrl = () => {
|
||||
const raw = env.DESKTOP_UPDATE_BASE_URL;
|
||||
requireEnv("DESKTOP_UPDATE_BASE_URL");
|
||||
if (!raw) return;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw.endsWith("/") ? raw : `${raw}/`);
|
||||
} catch (error) {
|
||||
fail(`DESKTOP_UPDATE_BASE_URL is invalid: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(url.protocol === "https:", "DESKTOP_UPDATE_BASE_URL must use HTTPS.");
|
||||
assert(url.username === "" && url.password === "", "DESKTOP_UPDATE_BASE_URL must not include credentials.");
|
||||
assert(!/[?&]token=/i.test(url.search), "DESKTOP_UPDATE_BASE_URL must not include token query parameters.");
|
||||
assert(
|
||||
url.pathname.includes(packageInfo.version),
|
||||
`DESKTOP_UPDATE_BASE_URL must include the immutable version segment ${packageInfo.version}.`,
|
||||
);
|
||||
};
|
||||
|
||||
const headSha = gitMaybe(["rev-parse", "HEAD"]);
|
||||
const exactTag = gitMaybe(["describe", "--tags", "--exact-match", "HEAD"]);
|
||||
const status = gitMaybe(["status", "--porcelain"]);
|
||||
|
||||
assert(Boolean(headSha), "Current Git commit cannot be resolved.");
|
||||
assert(exactTag === expectedTag, `Current commit must be exactly tagged ${expectedTag}; found ${exactTag || "<none>"}.`);
|
||||
assert(status === "", "Release readiness requires a clean working tree.");
|
||||
|
||||
assert(env.VITE_BUILD_CHANNEL === "release", "VITE_BUILD_CHANNEL must be release.");
|
||||
assert(fullShaPattern.test(env.VITE_BUILD_COMMIT || ""), "VITE_BUILD_COMMIT must be the full release commit SHA.");
|
||||
if (headSha && env.VITE_BUILD_COMMIT) {
|
||||
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
|
||||
}
|
||||
|
||||
for (const name of requiredSecretLikeEnv) {
|
||||
requireEnv(name);
|
||||
}
|
||||
|
||||
assert(
|
||||
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
|
||||
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
|
||||
);
|
||||
if (env.APPLE_CERTIFICATE) {
|
||||
requireEnv("APPLE_CERTIFICATE_PASSWORD");
|
||||
}
|
||||
|
||||
validateBaseUrl();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Desktop release readiness check passed.");
|
||||
}
|
||||
@@ -182,6 +182,19 @@ const verifyUpdaterBoundary = async () => {
|
||||
};
|
||||
|
||||
const verifyWorkflowGates = async () => {
|
||||
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
|
||||
const requiredScripts = [
|
||||
"desktop:build:macos-release",
|
||||
"desktop:update-feed:create",
|
||||
"desktop:update-feed:check",
|
||||
"desktop:release-readiness:check",
|
||||
"release:env:check",
|
||||
];
|
||||
|
||||
for (const script of requiredScripts) {
|
||||
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
|
||||
}
|
||||
|
||||
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
|
||||
const requiredCommands = [
|
||||
"npm run version:check",
|
||||
@@ -200,6 +213,24 @@ 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.");
|
||||
|
||||
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
|
||||
const requiredReleaseWorkflowTokens = [
|
||||
"REQUIRE_DESKTOP_SIGNING",
|
||||
"TAURI_SIGNING_PRIVATE_KEY",
|
||||
"APPLE_ID",
|
||||
"APPLE_PASSWORD",
|
||||
"APPLE_TEAM_ID",
|
||||
"npm run desktop:build:macos-release",
|
||||
"npm run desktop:update-feed:create",
|
||||
"npm run desktop:update-feed:check",
|
||||
"npm run desktop:release-readiness:check",
|
||||
"actions/upload-artifact",
|
||||
];
|
||||
|
||||
for (const token of requiredReleaseWorkflowTokens) {
|
||||
assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`);
|
||||
}
|
||||
};
|
||||
|
||||
await verifyTauriConfig();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -18,6 +19,10 @@ const feedPath = resolve(
|
||||
);
|
||||
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
|
||||
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
||||
const checksumManifestPath =
|
||||
optionValue("--checksum-manifest") ||
|
||||
process.env.DESKTOP_UPDATE_CHECKSUM_MANIFEST ||
|
||||
(artifactDir ? resolve(artifactDir, "SHA256SUMS.txt") : undefined);
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
@@ -32,6 +37,55 @@ const assertFileExists = async (path, description) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sha256 = async (path) => createHash("sha256").update(await readFile(path)).digest("hex");
|
||||
|
||||
const readChecksumManifest = async () => {
|
||||
if (!checksumManifestPath) return undefined;
|
||||
try {
|
||||
const source = await readFile(checksumManifestPath, "utf8");
|
||||
const checksums = new Map();
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
|
||||
if (!match) {
|
||||
fail(`Checksum manifest contains an invalid line: ${line}`);
|
||||
continue;
|
||||
}
|
||||
checksums.set(basename(match[2]), match[1].toLowerCase());
|
||||
}
|
||||
return checksums;
|
||||
} catch (error) {
|
||||
fail(`Cannot read checksum manifest ${checksumManifestPath}: ${error.message}`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const assertChecksum = async (checksums, path, description) => {
|
||||
if (!checksums) return;
|
||||
const name = basename(path);
|
||||
const expected = checksums.get(name);
|
||||
assert(Boolean(expected), `Checksum manifest must include ${description}: ${name}`);
|
||||
if (expected) {
|
||||
const actual = await sha256(path);
|
||||
assert(actual === expected, `${description} checksum mismatch for ${name}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertManifestEntries = async (checksums) => {
|
||||
if (!checksums || !artifactDir) return;
|
||||
for (const [name, expected] of checksums.entries()) {
|
||||
const path = resolve(artifactDir, name);
|
||||
await assertFileExists(path, `checksum manifest entry ${name}`);
|
||||
try {
|
||||
const actual = await sha256(path);
|
||||
assert(actual === expected, `Checksum manifest entry mismatch for ${name}.`);
|
||||
} catch (error) {
|
||||
fail(`Cannot verify checksum manifest entry ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let feed;
|
||||
try {
|
||||
feed = JSON.parse(await readFile(feedPath, "utf8"));
|
||||
@@ -40,6 +94,8 @@ try {
|
||||
}
|
||||
|
||||
if (feed) {
|
||||
const checksums = await readChecksumManifest();
|
||||
await assertManifestEntries(checksums);
|
||||
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
|
||||
const platforms = feed.platforms || {};
|
||||
const darwinArm = platforms["darwin-aarch64"];
|
||||
@@ -80,14 +136,21 @@ if (feed) {
|
||||
|
||||
if (artifactDir) {
|
||||
const artifactPath = resolve(artifactDir, basename(url.pathname));
|
||||
const signaturePath = `${artifactPath}.sig`;
|
||||
await assertFileExists(artifactPath, `${platform} updater artifact`);
|
||||
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
|
||||
await assertFileExists(signaturePath, `${platform} updater artifact signature`);
|
||||
await assertChecksum(checksums, artifactPath, `${platform} updater artifact`);
|
||||
await assertChecksum(checksums, signaturePath, `${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 (artifactDir) {
|
||||
await assertChecksum(checksums, feedPath, "latest.json");
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
||||
@@ -47,6 +47,9 @@ if (isTagBuild) {
|
||||
|
||||
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
|
||||
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");
|
||||
@@ -56,6 +59,9 @@ if (env.REQUIRE_DESKTOP_SIGNING === "true") {
|
||||
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
|
||||
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
|
||||
);
|
||||
if (env.APPLE_CERTIFICATE) {
|
||||
requireEnv("APPLE_CERTIFICATE_PASSWORD");
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user