feat(continuity): automate publish and cache finalization
This commit is contained in:
parent
03a376a100
commit
0756ec4258
4 changed files with 792 additions and 0 deletions
|
|
@ -11,3 +11,28 @@
|
|||
|
||||
本模块是策略事实源和回归样例,不代表 JZAO 上的本地协调运行时已经部署到服务器。运行时
|
||||
升级、仓库发布和服务器部署仍分别举证。
|
||||
|
||||
## 唯一自动收口入口
|
||||
|
||||
`finalize-development.mjs` 把开发任务的最后一步固化为一个失败关闭的事务:
|
||||
|
||||
1. 要求工作树干净,且至少一份 `deployment/receipts/` 回执已经进入当前提交;
|
||||
2. 只允许向已登记的第五域光湖代码频道仓库及精确分支申请发布队列;
|
||||
3. 快进推送当前完整提交;
|
||||
4. 用全新远端副本回读完整 SHA、运行 `git fsck` 并逐份比对回执 SHA-256;
|
||||
5. 只清理本工作树内、被 Git 忽略、命中缓存白名单且无活跃进程占用的目录;
|
||||
6. 写入一份紧凑的自动收口回执,释放本车道全部租约,完成车道并停止普通心跳。
|
||||
|
||||
任何一步失败都会保留车道为未完成;“本地提交完成”不能再冒充“第五域代码频道已经
|
||||
接收”。示例:
|
||||
|
||||
```bash
|
||||
node finalize-development.mjs \
|
||||
--development-id DEV-YYYYMMDD-NNN \
|
||||
--worktree /absolute/path/to/repository \
|
||||
--receipt deployment/receipts/EXACT-RECEIPT.json
|
||||
```
|
||||
|
||||
默认自动识别 `target`、`coverage`、Python/Node 工具缓存等安全候选;其他缓存必须用
|
||||
`--cleanup-path` 明确声明,并继续接受同一套路径、Git 忽略和活跃进程门禁。源码、会话、
|
||||
人格记忆、用户输出、交付物及未被忽略的目录永不进入自动删除范围。
|
||||
|
|
|
|||
635
server-tools/lake-lamp-continuity-guard/finalize-development.mjs
Normal file
635
server-tools/lake-lamp-continuity-guard/finalize-development.mjs
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const DEFAULT_GUARD =
|
||||
"/Volumes/JZAO/HoloLake/persona-runtime/continuity-guard/scripts/continuity-lane.mjs";
|
||||
const DEFAULT_STORE =
|
||||
"/Volumes/JZAO/HoloLake/persona-runtime/continuity-memory/collaboration";
|
||||
const CODE_CHANNEL_HOST = "guanghulab.com";
|
||||
const CODE_CHANNEL_PREFIX = "/code/bingshuo/";
|
||||
const CODE_CHANNEL_REPOSITORIES = new Set([
|
||||
"guanghu-ice-heart",
|
||||
"hololake-system-architecture",
|
||||
"grok-build-upstream-mirror",
|
||||
]);
|
||||
const SAFE_CACHE_RELATIVE = [
|
||||
/^target$/u,
|
||||
/^coverage$/u,
|
||||
/^\.nyc_output$/u,
|
||||
/^\.pytest_cache$/u,
|
||||
/^\.mypy_cache$/u,
|
||||
/^\.ruff_cache$/u,
|
||||
/^\.cache$/u,
|
||||
/^\.next\/cache$/u,
|
||||
/^node_modules\/\.cache$/u,
|
||||
/(?:^|\/)__pycache__$/u,
|
||||
];
|
||||
const DEFAULT_CACHE_CANDIDATES = [
|
||||
"target",
|
||||
"coverage",
|
||||
".nyc_output",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".cache",
|
||||
".next/cache",
|
||||
"node_modules/.cache",
|
||||
];
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const args = { receipts: [], cleanupPaths: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const key = argv[index];
|
||||
if (key === "--self-test") args.selfTest = true;
|
||||
else if (key === "--receipt") args.receipts.push(argv[++index]);
|
||||
else if (key === "--cleanup-path") args.cleanupPaths.push(argv[++index]);
|
||||
else if (key.startsWith("--")) {
|
||||
const name = key.slice(2).replaceAll(/-([a-z])/gu, (_, letter) =>
|
||||
letter.toUpperCase(),
|
||||
);
|
||||
args[name] = argv[++index];
|
||||
} else {
|
||||
throw new Error(`UNKNOWN_ARGUMENT:${key}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function normalizeCodeChannelRepository(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(value || "").trim().replace(/^repo:\/\//u, "https://"));
|
||||
} catch {
|
||||
throw new Error("INVALID_CODE_CHANNEL_REPOSITORY");
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new Error("CODE_CHANNEL_CREDENTIALS_FORBIDDEN");
|
||||
}
|
||||
const pathname = url.pathname.replace(/\.git$/u, "").replace(/\/+$/u, "");
|
||||
const slug = pathname.startsWith(CODE_CHANNEL_PREFIX)
|
||||
? pathname.slice(CODE_CHANNEL_PREFIX.length)
|
||||
: "";
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.hostname !== CODE_CHANNEL_HOST ||
|
||||
!CODE_CHANNEL_REPOSITORIES.has(slug)
|
||||
) {
|
||||
throw new Error("NON_CANONICAL_CODE_CHANNEL_REPOSITORY");
|
||||
}
|
||||
return {
|
||||
identity: `repo://${CODE_CHANNEL_HOST}${CODE_CHANNEL_PREFIX}${slug}`,
|
||||
cloneUrl: `https://${CODE_CHANNEL_HOST}${CODE_CHANNEL_PREFIX}${slug}.git`,
|
||||
slug,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBranch(value) {
|
||||
const branch = String(value || "").trim();
|
||||
if (
|
||||
!branch ||
|
||||
!/^(?!\/)(?!.*(?:\.\.|\/\/|@\{|[~^:?*\[\\]))[A-Za-z0-9._/-]+$/u.test(
|
||||
branch,
|
||||
) ||
|
||||
branch.endsWith("/") ||
|
||||
branch.endsWith(".")
|
||||
) {
|
||||
throw new Error("INVALID_TARGET_BRANCH");
|
||||
}
|
||||
return branch;
|
||||
}
|
||||
|
||||
export function normalizeReceipt(value) {
|
||||
const relative = String(value || "").replaceAll("\\", "/").replace(/^\.\/+/u, "");
|
||||
if (
|
||||
!relative.startsWith("deployment/receipts/") ||
|
||||
relative.includes("/../") ||
|
||||
relative.endsWith("/") ||
|
||||
!/^[A-Za-z0-9._/-]+$/u.test(relative)
|
||||
) {
|
||||
throw new Error("INVALID_DEPLOYMENT_RECEIPT_PATH");
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
export function safeCacheRelative(worktree, candidate) {
|
||||
const root = fs.realpathSync(worktree);
|
||||
const absolute = path.resolve(root, candidate);
|
||||
const relative = path.relative(root, absolute).replaceAll(path.sep, "/");
|
||||
if (
|
||||
!relative ||
|
||||
relative.startsWith("../") ||
|
||||
path.isAbsolute(relative) ||
|
||||
!SAFE_CACHE_RELATIVE.some((pattern) => pattern.test(relative))
|
||||
) {
|
||||
throw new Error(`CACHE_PATH_NOT_ALLOWLISTED:${candidate}`);
|
||||
}
|
||||
return { absolute, relative };
|
||||
}
|
||||
|
||||
function run(file, args, options = {}) {
|
||||
return execFileSync(file, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: options.stdio || ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function git(worktree, args) {
|
||||
return run("/usr/bin/git", ["-C", worktree, ...args]);
|
||||
}
|
||||
|
||||
function guard(guardScript, args) {
|
||||
const output = run("/usr/bin/node", [guardScript, ...args]);
|
||||
return output ? JSON.parse(output) : null;
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function listOwnedLeases(storeRoot, developmentId) {
|
||||
const directory = path.join(storeRoot, "leases");
|
||||
if (!fs.existsSync(directory)) return [];
|
||||
return fs
|
||||
.readdirSync(directory)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => readJson(path.join(directory, name)))
|
||||
.filter((lease) => lease.development_id === developmentId);
|
||||
}
|
||||
|
||||
function ensureLane(storeRoot, developmentId, worktree) {
|
||||
const lanePath = path.join(storeRoot, "lanes", `${developmentId}.json`);
|
||||
const lane = readJson(lanePath);
|
||||
if (lane.status !== "active" && lane.status !== "waiting") {
|
||||
throw new Error(`LANE_NOT_RUNNABLE:${lane.status}`);
|
||||
}
|
||||
const owned = listOwnedLeases(storeRoot, developmentId);
|
||||
const resolvedWorktree = fs.realpathSync(worktree);
|
||||
if (
|
||||
!owned.some((lease) => {
|
||||
if (!path.isAbsolute(lease.resource)) return false;
|
||||
try {
|
||||
const resource = fs.realpathSync(lease.resource);
|
||||
return (
|
||||
resolvedWorktree === resource ||
|
||||
resolvedWorktree.startsWith(`${resource}${path.sep}`) ||
|
||||
resource.startsWith(`${resolvedWorktree}${path.sep}`)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
) {
|
||||
throw new Error("WORKTREE_WRITE_LEASE_REQUIRED");
|
||||
}
|
||||
return lane;
|
||||
}
|
||||
|
||||
function verifyWorktree(worktree, receipts) {
|
||||
const root = fs.realpathSync(git(worktree, ["rev-parse", "--show-toplevel"]));
|
||||
if (root !== fs.realpathSync(worktree)) {
|
||||
throw new Error("WORKTREE_MUST_BE_GIT_ROOT");
|
||||
}
|
||||
if (git(root, ["status", "--porcelain=v1"])) {
|
||||
throw new Error("WORKTREE_NOT_CLEAN");
|
||||
}
|
||||
const head = git(root, ["rev-parse", "HEAD"]);
|
||||
if (!/^[0-9a-f]{40}$/u.test(head)) throw new Error("INVALID_LOCAL_HEAD");
|
||||
const branch = normalizeBranch(git(root, ["branch", "--show-current"]));
|
||||
const receiptEvidence = receipts.map((receipt) => {
|
||||
const relative = normalizeReceipt(receipt);
|
||||
const content = git(root, ["show", `HEAD:${relative}`]);
|
||||
if (!content) throw new Error(`RECEIPT_NOT_COMMITTED:${relative}`);
|
||||
return {
|
||||
path: relative,
|
||||
sha256: sha256(Buffer.from(content)),
|
||||
bytes: Buffer.byteLength(content),
|
||||
};
|
||||
});
|
||||
return { root, head, branch, receiptEvidence };
|
||||
}
|
||||
|
||||
function startPublish({
|
||||
guardScript,
|
||||
developmentId,
|
||||
repository,
|
||||
branch,
|
||||
}) {
|
||||
const requested = guard(guardScript, [
|
||||
"publish-request",
|
||||
"--development-id",
|
||||
developmentId,
|
||||
"--repository",
|
||||
repository.identity,
|
||||
"--target",
|
||||
branch,
|
||||
"--priority",
|
||||
"normal",
|
||||
]);
|
||||
guard(guardScript, ["publish-dispatch"]);
|
||||
guard(guardScript, [
|
||||
"publish-start",
|
||||
"--development-id",
|
||||
developmentId,
|
||||
"--request-id",
|
||||
requested.request_id,
|
||||
]);
|
||||
return requested.request_id;
|
||||
}
|
||||
|
||||
function finishPublish(guardScript, developmentId, requestId, outcome, receipt) {
|
||||
return guard(guardScript, [
|
||||
"publish-finish",
|
||||
"--development-id",
|
||||
developmentId,
|
||||
"--request-id",
|
||||
requestId,
|
||||
"--outcome",
|
||||
outcome,
|
||||
"--receipt",
|
||||
receipt,
|
||||
]);
|
||||
}
|
||||
|
||||
function pushAndReadBack(worktree, repository, branch, head, receipts) {
|
||||
run("/usr/bin/git", [
|
||||
"-C",
|
||||
worktree,
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"origin",
|
||||
`refs/heads/${branch}`,
|
||||
]);
|
||||
const remoteBefore = git(worktree, [
|
||||
"rev-parse",
|
||||
`refs/remotes/origin/${branch}`,
|
||||
]);
|
||||
try {
|
||||
git(worktree, ["merge-base", "--is-ancestor", remoteBefore, head]);
|
||||
} catch {
|
||||
throw new Error(`NON_FAST_FORWARD_REFUSED:${remoteBefore}`);
|
||||
}
|
||||
run("/usr/bin/git", [
|
||||
"-C",
|
||||
worktree,
|
||||
"push",
|
||||
"origin",
|
||||
`HEAD:refs/heads/${branch}`,
|
||||
]);
|
||||
const remoteLine = run("/usr/bin/git", [
|
||||
"ls-remote",
|
||||
repository.cloneUrl,
|
||||
`refs/heads/${branch}`,
|
||||
]);
|
||||
const remoteHead = remoteLine.split(/\s+/u)[0] || "";
|
||||
if (remoteHead !== head) {
|
||||
throw new Error(`REMOTE_HEAD_MISMATCH:${remoteHead || "missing"}`);
|
||||
}
|
||||
|
||||
const readbackRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "hololake-finalize-readback-"),
|
||||
);
|
||||
try {
|
||||
const clone = path.join(readbackRoot, "repo");
|
||||
run("/usr/bin/git", [
|
||||
"clone",
|
||||
"--quiet",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
branch,
|
||||
repository.cloneUrl,
|
||||
clone,
|
||||
]);
|
||||
const cloneHead = git(clone, ["rev-parse", "HEAD"]);
|
||||
if (cloneHead !== head) throw new Error(`FRESH_CLONE_HEAD_MISMATCH:${cloneHead}`);
|
||||
git(clone, ["fsck", "--full"]);
|
||||
for (const receipt of receipts) {
|
||||
const remoteContent = git(clone, ["show", `HEAD:${receipt.path}`]);
|
||||
if (sha256(Buffer.from(remoteContent)) !== receipt.sha256) {
|
||||
throw new Error(`REMOTE_RECEIPT_MISMATCH:${receipt.path}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(readbackRoot, { recursive: true, force: true });
|
||||
}
|
||||
return { remoteBefore, remoteHead };
|
||||
}
|
||||
|
||||
function activeProcessFor(absolute) {
|
||||
const output = run("/bin/ps", ["-axo", "pid=,command="]);
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.some((line) => {
|
||||
const match = line.match(/^(\d+)\s+(.*)$/u);
|
||||
if (!match) return false;
|
||||
const pid = Number(match[1]);
|
||||
return pid !== process.pid && pid !== process.ppid && match[2].includes(absolute);
|
||||
});
|
||||
}
|
||||
|
||||
function directoryBytes(root) {
|
||||
let total = 0;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
const candidate = path.join(root, entry.name);
|
||||
const stat = fs.lstatSync(candidate);
|
||||
if (stat.isSymbolicLink()) continue;
|
||||
if (stat.isDirectory()) total += directoryBytes(candidate);
|
||||
else if (stat.isFile()) total += stat.size;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function collectPythonCaches(root, limit = 256) {
|
||||
const found = [];
|
||||
const walk = (directory, depth) => {
|
||||
if (depth > 6 || found.length >= limit) return;
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
if (found.length >= limit) return;
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.name === "__pycache__") {
|
||||
found.push(path.relative(root, candidate));
|
||||
continue;
|
||||
}
|
||||
if ([".git", "node_modules", "target", "build", "dist"].includes(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
walk(candidate, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(root, 0);
|
||||
return found;
|
||||
}
|
||||
|
||||
function cleanupCaches(worktree, explicitPaths) {
|
||||
const candidates = [
|
||||
...DEFAULT_CACHE_CANDIDATES,
|
||||
...collectPythonCaches(worktree),
|
||||
...explicitPaths,
|
||||
];
|
||||
const unique = new Map();
|
||||
for (const candidate of candidates) {
|
||||
const safe = safeCacheRelative(worktree, candidate);
|
||||
unique.set(safe.absolute, safe);
|
||||
}
|
||||
const results = [];
|
||||
for (const item of unique.values()) {
|
||||
if (!fs.existsSync(item.absolute)) {
|
||||
results.push({ path: item.relative, result: "SKIPPED_NOT_PRESENT", bytes: 0 });
|
||||
continue;
|
||||
}
|
||||
const stat = fs.lstatSync(item.absolute);
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw new Error(`CACHE_TARGET_NOT_REAL_DIRECTORY:${item.relative}`);
|
||||
}
|
||||
if (git(worktree, ["ls-files", "--", item.relative])) {
|
||||
throw new Error(`TRACKED_CACHE_REFUSED:${item.relative}`);
|
||||
}
|
||||
try {
|
||||
git(worktree, ["check-ignore", "--no-index", "-q", "--", item.relative]);
|
||||
} catch {
|
||||
throw new Error(`UNIGNORED_CACHE_REFUSED:${item.relative}`);
|
||||
}
|
||||
if (activeProcessFor(item.absolute)) {
|
||||
results.push({ path: item.relative, result: "SKIPPED_ACTIVE", bytes: 0 });
|
||||
continue;
|
||||
}
|
||||
const bytes = directoryBytes(item.absolute);
|
||||
fs.rmSync(item.absolute, { recursive: true, force: true });
|
||||
results.push({ path: item.relative, result: "REMOVED", bytes });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function writeFinalizationReceipt(storeRoot, developmentId, payload) {
|
||||
const directory = path.join(storeRoot, "finalization-receipts");
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
const filePath = path.join(
|
||||
directory,
|
||||
`${developmentId}-${new Date().toISOString().replaceAll(/[:.]/gu, "-")}.json`,
|
||||
);
|
||||
const temporary = `${filePath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(temporary, filePath);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function releaseAndComplete({
|
||||
guardScript,
|
||||
storeRoot,
|
||||
developmentId,
|
||||
summary,
|
||||
}) {
|
||||
for (const lease of listOwnedLeases(storeRoot, developmentId)) {
|
||||
guard(guardScript, [
|
||||
"release",
|
||||
"--development-id",
|
||||
developmentId,
|
||||
"--resource",
|
||||
lease.resource,
|
||||
]);
|
||||
}
|
||||
const lane = guard(guardScript, [
|
||||
"complete",
|
||||
"--development-id",
|
||||
developmentId,
|
||||
"--summary",
|
||||
summary,
|
||||
]);
|
||||
const actions = guard(guardScript, ["completion-actions"]);
|
||||
const current = actions.find(
|
||||
(item) => item.development_id === developmentId,
|
||||
);
|
||||
if (!current) throw new Error("COMPLETION_ACTION_MISSING");
|
||||
if ((current.delete_automation_ids || []).length === 0) {
|
||||
guard(guardScript, [
|
||||
"mark-completion-stopped",
|
||||
"--development-id",
|
||||
developmentId,
|
||||
]);
|
||||
return { lane_status: lane.status, notification_state: "stopped" };
|
||||
}
|
||||
return {
|
||||
lane_status: lane.status,
|
||||
notification_state: "stop_requested",
|
||||
delete_automation_ids: current.delete_automation_ids,
|
||||
};
|
||||
}
|
||||
|
||||
export async function finalize(options) {
|
||||
const developmentId = String(options.developmentId || "");
|
||||
if (!/^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$/u.test(developmentId)) {
|
||||
throw new Error("INVALID_DEVELOPMENT_ID");
|
||||
}
|
||||
const guardScript = options.guardScript || DEFAULT_GUARD;
|
||||
const storeRoot = options.storeRoot || DEFAULT_STORE;
|
||||
const worktree = fs.realpathSync(options.worktree || "");
|
||||
if (!options.receipts?.length) throw new Error("AT_LEAST_ONE_RECEIPT_REQUIRED");
|
||||
ensureLane(storeRoot, developmentId, worktree);
|
||||
const verified = verifyWorktree(worktree, options.receipts);
|
||||
const repository = normalizeCodeChannelRepository(
|
||||
options.repository || git(worktree, ["config", "--get", "remote.origin.url"]),
|
||||
);
|
||||
const branch = normalizeBranch(options.target || verified.branch);
|
||||
if (branch !== verified.branch) throw new Error("CHECKED_OUT_BRANCH_MISMATCH");
|
||||
|
||||
let publishRequestId = null;
|
||||
let publishStarted = false;
|
||||
try {
|
||||
publishRequestId = startPublish({
|
||||
guardScript,
|
||||
developmentId,
|
||||
repository,
|
||||
branch,
|
||||
});
|
||||
publishStarted = true;
|
||||
const remote = pushAndReadBack(
|
||||
worktree,
|
||||
repository,
|
||||
branch,
|
||||
verified.head,
|
||||
verified.receiptEvidence,
|
||||
);
|
||||
const publishReceipt =
|
||||
`PASS_100 remote=${repository.identity}#${branch} ` +
|
||||
`head=${remote.remoteHead} receipts=${verified.receiptEvidence
|
||||
.map((item) => `${item.path}:${item.sha256}`)
|
||||
.join(",")}`;
|
||||
finishPublish(
|
||||
guardScript,
|
||||
developmentId,
|
||||
publishRequestId,
|
||||
"completed",
|
||||
publishReceipt,
|
||||
);
|
||||
publishStarted = false;
|
||||
|
||||
const cleanup = cleanupCaches(worktree, options.cleanupPaths || []);
|
||||
const removedBytes = cleanup
|
||||
.filter((item) => item.result === "REMOVED")
|
||||
.reduce((total, item) => total + item.bytes, 0);
|
||||
const payload = {
|
||||
schema: "hololake.development-finalization-receipt/v1",
|
||||
development_id: developmentId,
|
||||
result: "PASS_100",
|
||||
code_channel: {
|
||||
repository: repository.identity,
|
||||
branch,
|
||||
remote_head: remote.remoteHead,
|
||||
fresh_clone_readback: 100,
|
||||
git_fsck: 100,
|
||||
receipts: verified.receiptEvidence,
|
||||
},
|
||||
cleanup: {
|
||||
scope: "CURRENT_WORKTREE_IGNORED_ALLOWLISTED_CACHES_ONLY",
|
||||
removed_bytes: removedBytes,
|
||||
items: cleanup,
|
||||
},
|
||||
recorded_at: new Date().toISOString(),
|
||||
};
|
||||
const finalizationReceipt = writeFinalizationReceipt(
|
||||
storeRoot,
|
||||
developmentId,
|
||||
payload,
|
||||
);
|
||||
const completion = releaseAndComplete({
|
||||
guardScript,
|
||||
storeRoot,
|
||||
developmentId,
|
||||
summary:
|
||||
`自动收口PASS_100:第五域代码频道${repository.identity}#${branch}` +
|
||||
`远端回读${remote.remoteHead};${verified.receiptEvidence.length}份回执一致;` +
|
||||
`本车道安全缓存清理${removedBytes}字节;回执${finalizationReceipt}`,
|
||||
});
|
||||
return { ...payload, finalization_receipt: finalizationReceipt, completion };
|
||||
} catch (error) {
|
||||
if (publishStarted && publishRequestId) {
|
||||
try {
|
||||
finishPublish(
|
||||
guardScript,
|
||||
developmentId,
|
||||
publishRequestId,
|
||||
"failed",
|
||||
`FAIL_0 ${String(error.message || error).slice(0, 400)}`,
|
||||
);
|
||||
} catch {
|
||||
// The original finalization failure remains authoritative.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function selfTest() {
|
||||
const canonical = normalizeCodeChannelRepository(
|
||||
"https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git",
|
||||
);
|
||||
if (
|
||||
canonical.identity !==
|
||||
"repo://guanghulab.com/code/bingshuo/guanghu-ice-heart"
|
||||
) {
|
||||
throw new Error("SELF_TEST_REPOSITORY_NORMALIZATION_FAILED");
|
||||
}
|
||||
for (const invalid of [
|
||||
"https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git",
|
||||
"https://user:secret@guanghulab.com/code/bingshuo/guanghu-ice-heart.git",
|
||||
]) {
|
||||
let rejected = false;
|
||||
try {
|
||||
normalizeCodeChannelRepository(invalid);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
if (!rejected) throw new Error("SELF_TEST_INVALID_REPOSITORY_ACCEPTED");
|
||||
}
|
||||
if (
|
||||
normalizeReceipt(
|
||||
"deployment/receipts/GLW-CHJH-ROUTER-CONTROLLER-JD-20260805.json",
|
||||
) !==
|
||||
"deployment/receipts/GLW-CHJH-ROUTER-CONTROLLER-JD-20260805.json"
|
||||
) {
|
||||
throw new Error("SELF_TEST_RECEIPT_NORMALIZATION_FAILED");
|
||||
}
|
||||
return { result: "PASS_100" };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const result = args.selfTest
|
||||
? selfTest()
|
||||
: await finalize({
|
||||
developmentId: args.developmentId,
|
||||
worktree: args.worktree,
|
||||
repository: args.repository,
|
||||
target: args.target,
|
||||
receipts: args.receipts,
|
||||
cleanupPaths: args.cleanupPaths,
|
||||
guardScript: args.guardScript,
|
||||
storeRoot: args.storeRoot,
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FINALIZATION_FAIL_0:${String(error.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
normalizeBranch,
|
||||
normalizeCodeChannelRepository,
|
||||
normalizeReceipt,
|
||||
parseArgs,
|
||||
safeCacheRelative,
|
||||
selfTest,
|
||||
} from "./finalize-development.mjs";
|
||||
|
||||
test("the finalizer accepts only the registered Fifth Domain code channel", () => {
|
||||
assert.equal(
|
||||
normalizeCodeChannelRepository(
|
||||
"https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git",
|
||||
).identity,
|
||||
"repo://guanghulab.com/code/bingshuo/guanghu-ice-heart",
|
||||
);
|
||||
for (const invalid of [
|
||||
"https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git",
|
||||
"https://example.com/code/bingshuo/guanghu-ice-heart.git",
|
||||
"https://user:secret@guanghulab.com/code/bingshuo/guanghu-ice-heart.git",
|
||||
]) {
|
||||
assert.throws(() => normalizeCodeChannelRepository(invalid));
|
||||
}
|
||||
});
|
||||
|
||||
test("receipt and branch validation fail closed", () => {
|
||||
assert.equal(normalizeBranch("main"), "main");
|
||||
assert.equal(
|
||||
normalizeReceipt("deployment/receipts/EXACT.json"),
|
||||
"deployment/receipts/EXACT.json",
|
||||
);
|
||||
assert.throws(() => normalizeReceipt("../EXACT.json"));
|
||||
assert.throws(() => normalizeReceipt("docs/EXACT.json"));
|
||||
assert.throws(() => normalizeBranch("../main"));
|
||||
});
|
||||
|
||||
test("repeated receipts and cleanup paths are retained", () => {
|
||||
const parsed = parseArgs([
|
||||
"--development-id",
|
||||
"DEV-20260805-003",
|
||||
"--receipt",
|
||||
"deployment/receipts/A.json",
|
||||
"--receipt",
|
||||
"deployment/receipts/B.json",
|
||||
"--cleanup-path",
|
||||
"target",
|
||||
]);
|
||||
assert.deepEqual(parsed.receipts, [
|
||||
"deployment/receipts/A.json",
|
||||
"deployment/receipts/B.json",
|
||||
]);
|
||||
assert.deepEqual(parsed.cleanupPaths, ["target"]);
|
||||
});
|
||||
|
||||
test("cache deletion scope accepts only exact allowlisted descendants", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "finalizer-cache-test-"));
|
||||
try {
|
||||
assert.equal(safeCacheRelative(root, "target").relative, "target");
|
||||
assert.equal(
|
||||
safeCacheRelative(root, "src/__pycache__").relative,
|
||||
"src/__pycache__",
|
||||
);
|
||||
assert.throws(() => safeCacheRelative(root, "."));
|
||||
assert.throws(() => safeCacheRelative(root, "../outside"));
|
||||
assert.throws(() => safeCacheRelative(root, "outputs"));
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("embedded finalizer self-test passes", () => {
|
||||
assert.deepEqual(selfTest(), { result: "PASS_100" });
|
||||
});
|
||||
Loading…
Reference in a new issue