fix: 非运行态提交跳过 Todo 发布

This commit is contained in:
Codex
2026-07-10 06:59:59 +02:00
parent f128d73663
commit 5968a93a4b
7 changed files with 200 additions and 21 deletions
@@ -8,11 +8,12 @@ metadata_file=${UNIDESK_HOST_BUILD_METADATA_FILE:-/workspace/build-metadata.json
build_log=${UNIDESK_HOST_BUILD_LOG:-/workspace/image-build.log}
source_commit=${SOURCE_COMMIT:?SOURCE_COMMIT is required}
enabled=$(cat "$release_dir/enabled")
if [ "$enabled" != true ]; then
action=$(cat "$release_dir/action")
reason=$(cat "$release_dir/reason")
if [ "$action" != build ]; then
: >"$digest_file"
: >"$metadata_file"
printf '{"ok":true,"phase":"image-build","status":"skipped","reason":"delivery-disabled","sourceCommit":"%s","valuesPrinted":false}\n' "$source_commit"
printf '{"ok":true,"phase":"image-build","status":"skipped","action":"%s","reason":"%s","sourceCommit":"%s","valuesPrinted":false}\n' "$action" "$reason" "$source_commit"
exit 0
fi
@@ -1,4 +1,5 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
@@ -20,6 +21,27 @@ function required(value, path) {
return value;
}
function safeRelativePath(value, path) {
const result = required(value, path);
if (result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${path} must be a safe relative path`);
return result;
}
function stringArray(value, path) {
if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} must be a non-empty string array`);
return value.map((item, index) => safeRelativePath(item, `${path}[${index}]`));
}
function run(command, args, cwd, allowFailure = false) {
const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
if (result.error !== undefined) throw result.error;
if (result.status !== 0 && !allowFailure) {
const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-2000);
throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${detail}`);
}
return result;
}
function valueAt(root, reference) {
return reference.split(".").reduce((value, key) => record(value, reference)[key], root);
}
@@ -30,20 +52,65 @@ function writeValue(outputDir, name, value) {
function main() {
const configPath = option("--config") ?? "config/unidesk-host-k8s.yaml";
const sourceRoot = resolve(option("--source-root") ?? process.cwd());
const sourceCommit = required(option("--source-commit"), "--source-commit");
const sourceSnapshotPrefix = required(option("--source-snapshot-prefix"), "--source-snapshot-prefix");
const outputDir = resolve(required(option("--output-dir"), "--output-dir"));
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA");
if (run("git", ["check-ref-format", sourceSnapshotPrefix], sourceRoot, true).status !== 0) throw new Error("--source-snapshot-prefix must be a valid Git ref prefix");
const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath);
const delivery = record(config.delivery, "delivery");
const serviceRef = required(delivery.serviceRef, "delivery.serviceRef");
const service = record(valueAt(config, serviceRef), serviceRef);
const changeDetection = record(delivery.changeDetection, "delivery.changeDetection");
const runtimePaths = stringArray(changeDetection.runtimePaths, "delivery.changeDetection.runtimePaths");
const build = record(delivery.build, "delivery.build");
const proxy = record(build.proxy, "delivery.build.proxy");
const image = record(delivery.image, "delivery.image");
const gitops = record(delivery.gitops, "delivery.gitops");
const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl");
const branch = required(gitops.branch, "delivery.gitops.branch");
const releaseStatePath = safeRelativePath(gitops.releaseStatePath, "delivery.gitops.releaseStatePath");
const enabled = delivery.enabled === true;
if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean");
const noProxy = proxy.noProxy;
if (!Array.isArray(noProxy) || noProxy.some((value) => typeof value !== "string" || value.length === 0)) throw new Error("delivery.build.proxy.noProxy must be a non-empty string array");
let action = enabled ? "build" : "disabled";
let reason = enabled ? "gitops-baseline-unavailable" : "delivery-disabled";
let baselineSourceCommit = null;
let changedPaths = [];
if (enabled) {
const gitopsRef = "refs/unidesk/release-baseline/unidesk-host";
const gitopsFetch = run("git", ["fetch", "--depth=1", readUrl, `+refs/heads/${branch}:${gitopsRef}`], sourceRoot, true);
if (gitopsFetch.status === 0) {
const stateResult = run("git", ["show", `${gitopsRef}:${releaseStatePath}`], sourceRoot, true);
if (stateResult.status === 0) {
const state = record(JSON.parse(stateResult.stdout), releaseStatePath);
if (state.kind !== "UniDeskHostReleaseState") throw new Error(`${releaseStatePath}.kind must be UniDeskHostReleaseState`);
if (state.serviceRef !== serviceRef) throw new Error(`${releaseStatePath}.serviceRef must match delivery.serviceRef`);
baselineSourceCommit = required(state.sourceCommit, `${releaseStatePath}.sourceCommit`);
if (!/^[0-9a-f]{40}$/u.test(baselineSourceCommit)) throw new Error(`${releaseStatePath}.sourceCommit must be a full Git commit SHA`);
const sourceRef = "refs/unidesk/release-baseline/unidesk-host-source";
const sourceFetch = run("git", ["fetch", "--depth=1", "--filter=blob:none", "origin", `+${sourceSnapshotPrefix}/${baselineSourceCommit}:${sourceRef}`], sourceRoot, true);
if (sourceFetch.status === 0) {
const diff = run("git", ["diff", "--name-only", "--no-renames", sourceRef, sourceCommit, "--", ...runtimePaths], sourceRoot);
changedPaths = diff.stdout.split(/\r?\n/u).filter(Boolean);
action = changedPaths.length === 0 ? "skip" : "build";
reason = changedPaths.length === 0 ? "runtime-inputs-unchanged" : "runtime-inputs-changed";
} else {
reason = "source-baseline-unavailable";
}
} else {
reason = "release-state-missing";
}
}
}
mkdirSync(outputDir, { recursive: true });
writeValue(outputDir, "enabled", enabled ? "true" : "false");
writeValue(outputDir, "action", action);
writeValue(outputDir, "reason", reason);
writeValue(outputDir, "baseline-source-commit", baselineSourceCommit ?? "");
writeFileSync(resolve(outputDir, "changed-paths.json"), `${JSON.stringify(changedPaths)}\n`, { encoding: "utf8", mode: 0o644 });
writeValue(outputDir, "dockerfile", required(record(service.repository, `${serviceRef}.repository`).dockerfile, `${serviceRef}.repository.dockerfile`));
writeValue(outputDir, "image-repository", required(image.repository, "delivery.image.repository"));
writeValue(outputDir, "network-mode", required(build.networkMode, "delivery.build.networkMode"));
@@ -51,7 +118,7 @@ writeValue(outputDir, "http-proxy", required(proxy.http, "delivery.build.proxy.h
writeValue(outputDir, "https-proxy", required(proxy.https, "delivery.build.proxy.https"));
writeValue(outputDir, "all-proxy", required(proxy.all, "delivery.build.proxy.all"));
writeValue(outputDir, "no-proxy", noProxy.join(","));
process.stdout.write(`${JSON.stringify({ ok: true, action: "unidesk-host-release-prepare", enabled, serviceRef, valuesPrinted: false })}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, phase: "release-prepare", action, reason, enabled, serviceRef, sourceCommit, baselineSourceCommit, changedPaths, valuesPrinted: false })}\n`);
}
if (!process.execArgv.includes("--check")) main();
@@ -37,10 +37,47 @@ function safeManifestPath(value) {
return path;
}
function safeReleaseStatePath(value) {
const path = required(value, "delivery.gitops.releaseStatePath");
if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.releaseStatePath must be a safe relative path");
return path;
}
function releaseValue(releaseDir, name) {
return required(readFileSync(resolve(releaseDir, name), "utf8").trim(), `${releaseDir}/${name}`);
}
function removeFile(path) {
try {
unlinkSync(path);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
function readReleaseState(path, serviceRef) {
let value;
try {
value = record(JSON.parse(readFileSync(path, "utf8")), path);
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
if (value.kind !== "UniDeskHostReleaseState") throw new Error(`${path}.kind must be UniDeskHostReleaseState`);
if (value.serviceRef !== serviceRef) throw new Error(`${path}.serviceRef must match delivery.serviceRef`);
const sourceCommit = required(value.sourceCommit, `${path}.sourceCommit`);
const digest = required(value.digest, `${path}.digest`);
const digestRef = required(value.digestRef, `${path}.digestRef`);
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error(`${path}.sourceCommit must be a full Git commit SHA`);
if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error(`${path}.digest must be sha256:<64 hex>`);
return { sourceCommit, digest, digestRef };
}
function main() {
const configPath = resolve(option("--config") ?? "config/unidesk-host-k8s.yaml");
const sourceRoot = resolve(option("--source-root") ?? process.cwd());
const metadataPath = resolve(required(option("--metadata"), "--metadata"));
const releaseDir = resolve(required(option("--release-dir"), "--release-dir"));
const sourceCommit = required(option("--source-commit"), "--source-commit");
const worktree = resolve(option("--worktree") ?? "/workspace/unidesk-host-gitops");
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA");
@@ -49,6 +86,11 @@ const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPa
const delivery = record(config.delivery, "delivery");
if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean");
const enabled = delivery.enabled === true;
const action = releaseValue(releaseDir, "action");
const reason = releaseValue(releaseDir, "reason");
const baselineSourceCommit = readFileSync(resolve(releaseDir, "baseline-source-commit"), "utf8").trim() || null;
if (!new Set(["build", "skip", "disabled"]).has(action)) throw new Error(`${releaseDir}/action must be build, skip, or disabled`);
if ((enabled && action === "disabled") || (!enabled && action !== "disabled")) throw new Error("release action does not match delivery.enabled");
const serviceRef = required(delivery.serviceRef, "delivery.serviceRef");
const imageConfig = record(delivery.image, "delivery.image");
const gitops = record(delivery.gitops, "delivery.gitops");
@@ -57,15 +99,18 @@ const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl");
const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl");
const branch = required(gitops.branch, "delivery.gitops.branch");
const manifestPath = safeManifestPath(gitops.manifestPath);
const releaseStatePath = safeReleaseStatePath(gitops.releaseStatePath);
let digest = null;
let digestRef = null;
let manifest = null;
let runtimeSourceCommit = null;
if (enabled) {
if (action === "build") {
const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath);
digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`);
if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("containerimage.digest must be sha256:<64 hex>");
digestRef = `${required(imageConfig.repository, "delivery.image.repository")}@${digest}`;
runtimeSourceCommit = sourceCommit;
const render = run("bun", [
"scripts/native/deploy/render-unidesk-host-service.mjs",
"--config",
@@ -92,16 +137,30 @@ if (fetched) {
}
const targetPath = resolve(worktree, manifestPath);
const statePath = resolve(worktree, releaseStatePath);
if (!targetPath.startsWith(`${worktree}/`)) throw new Error("resolved manifest path escaped the GitOps worktree");
if (enabled && manifest !== null) {
if (!statePath.startsWith(`${worktree}/`)) throw new Error("resolved release state path escaped the GitOps worktree");
const existingState = readReleaseState(statePath, serviceRef);
if (action === "skip") {
if (existingState === null) throw new Error("skip requires an existing GitOps release state");
digest = existingState.digest;
digestRef = existingState.digestRef;
runtimeSourceCommit = existingState.sourceCommit;
} else if (action === "build" && manifest !== null) {
mkdirSync(dirname(targetPath), { recursive: true });
writeFileSync(targetPath, manifest, "utf8");
mkdirSync(dirname(statePath), { recursive: true });
writeFileSync(statePath, `${JSON.stringify({
version: 1,
kind: "UniDeskHostReleaseState",
serviceRef,
sourceCommit,
digest,
digestRef,
}, null, 2)}\n`, "utf8");
} else {
try {
unlinkSync(targetPath);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
removeFile(targetPath);
removeFile(statePath);
}
run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree);
@@ -109,8 +168,8 @@ run("git", ["config", "user.email", required(author.email, "delivery.gitops.auth
run("git", ["add", "-A"], worktree);
const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0;
if (changed) {
const action = enabled ? "deploy" : "remove";
run("git", ["commit", "-m", `unidesk-host: ${action} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree);
const commitAction = action === "build" ? "deploy" : "remove";
run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree);
}
run("git", ["remote", "set-url", "origin", writeUrl], worktree);
@@ -139,9 +198,13 @@ const gitopsCommit = head.status === 0 ? head.stdout.trim() : null;
process.stdout.write(`${JSON.stringify({
ok: true,
phase: "gitops-publish",
status: enabled ? (changed ? "built" : "reused") : "disabled",
imageStatus: enabled ? (changed ? "built" : "reused") : "disabled",
action,
reason,
status: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled",
imageStatus: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled",
sourceCommit,
baselineSourceCommit,
runtimeSourceCommit,
digest,
digestRef,
gitopsCommit,