Files
pikasTech-unidesk/scripts/src/hwlab-g14/cd-trigger.ts
T
2026-06-25 16:16:25 +00:00

674 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. cd-trigger module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:9508-10162 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult, G14MonitorOptions, OpenPullRequest } from "./types";
import { runRuntimeLaneControlPlane, runV02ControlPlane, runtimeLaneControlPlaneStatus, v02ControlPlaneStatus } from "./control-plane";
import { runG14GitMirror, runGitMirrorStatus } from "./git-mirror";
import { activeV02PipelineRuns, commentRuntimeLanePullRequest, commentV02PullRequest, durationSeconds, reportRuntimeLaneAutomationIssue, runtimeLaneCdFailed, runtimeLaneCdPassed, sourceCommitFromMergeResult, summarizeRuntimeLaneCdStatus, summarizeV02CdStatus, v02CdFailed, v02CdPassed, v02PipelineSucceeded, waitForV02Head } from "./pr-monitor";
import { commandData, compactCommandResult, isCommandSuccess, nested, printEvent, printRuntimeLanePrMonitorProgress, printV02PrMonitorProgress, record, sleep, stringOrNull } from "./remote";
import { resolveRuntimeLaneHead, runtimeLanePipelineRunName, v02PipelineRunName } from "./source";
export function triggerV02Current(timeoutSeconds: number): Record<string, unknown> {
return runV02ControlPlane({
action: "trigger-current",
lane: "v02",
dryRun: false,
confirm: true,
wait: true,
allowLiveDbRead: false,
timeoutSeconds,
minAgeMinutes: 30,
limit: 20,
});
}
export function flushV02GitMirrorIfNeeded(status: Record<string, unknown>, timeoutSeconds: number): Record<string, unknown> {
const pendingFlush = nested(status, ["gitMirror", "summary", "pendingFlush"]);
if (pendingFlush !== true) return { ok: true, skipped: true, reason: "git-mirror-already-flushed" };
return runG14GitMirror({
action: "flush",
lane: "v02",
confirm: true,
dryRun: false,
wait: true,
timeoutSeconds,
});
}
export function triggerRuntimeLaneCurrent(spec: HwlabRuntimeLaneSpec, timeoutSeconds: number): Record<string, unknown> {
return runRuntimeLaneControlPlane(spec, {
action: "trigger-current",
lane: spec.lane,
dryRun: false,
confirm: true,
wait: true,
allowLiveDbRead: false,
timeoutSeconds,
minAgeMinutes: 30,
limit: 20,
history: false,
});
}
export function runtimeLaneStatusWithGitMirror(spec: HwlabRuntimeLaneSpec, sourceCommit: string): Record<string, unknown> {
const status = runtimeLaneControlPlaneStatus(spec, { sourceCommit, mode: "source-commit" });
const gitMirrorStatus = runGitMirrorStatus(spec.lane);
return {
...status,
gitMirror: record(gitMirrorStatus.cache),
gitMirrorStatus,
};
}
export function runtimeLaneGitopsSynced(spec: HwlabRuntimeLaneSpec, status: Record<string, unknown>): boolean {
const summary = record(nested(status, ["gitMirror", "summary"]));
const inSync = spec.lane === "v03" ? summary.v03GitopsInSync : summary.githubInSync;
return summary.pendingFlush !== true && inSync === true;
}
export function runtimeLaneCdPassedWithMirror(spec: HwlabRuntimeLaneSpec, status: Record<string, unknown>): boolean {
return runtimeLaneCdPassed(status) && runtimeLaneGitopsSynced(spec, status);
}
export function flushRuntimeLaneGitMirrorIfNeeded(spec: HwlabRuntimeLaneSpec, status: Record<string, unknown>, timeoutSeconds: number): Record<string, unknown> {
if (runtimeLaneGitopsSynced(spec, status)) return { ok: true, skipped: true, reason: `${spec.lane}-git-mirror-already-flushed` };
return runG14GitMirror({
action: "flush",
lane: spec.lane,
confirm: true,
dryRun: false,
wait: true,
timeoutSeconds,
});
}
export function pullRequestFromMergeCommand(merge: CommandJsonResult): Record<string, unknown> {
const data = commandData(merge);
const direct = record(data.pullRequest);
if (Object.keys(direct).length > 0) return direct;
return record(nested(data, ["details", "details", "pullRequest"]));
}
export function mergeCommandRaceState(merge: CommandJsonResult): "merged" | "closed" | null {
const pullRequest = pullRequestFromMergeCommand(merge);
if (pullRequest.merged === true || pullRequest.stateDetail === "merged") return "merged";
if (pullRequest.closed === true || pullRequest.state === "closed") return "closed";
return null;
}
export async function waitForRuntimeLaneHead(spec: HwlabRuntimeLaneSpec, expectedCommit: string | null, timeoutSeconds: number): Promise<Record<string, unknown>> {
const started = Date.now();
let head: string | null = null;
while (Date.now() - started < timeoutSeconds * 1000) {
head = resolveRuntimeLaneHead(spec).sourceCommit;
if (expectedCommit === null) {
if (head !== null) return { ok: true, sourceCommit: head, expectedCommit, matched: true, waitedSeconds: Math.round((Date.now() - started) / 1000) };
} else if (head === expectedCommit) {
return { ok: true, sourceCommit: head, expectedCommit, matched: true, waitedSeconds: Math.round((Date.now() - started) / 1000) };
}
printEvent(`${spec.lane}.source.wait`, { lane: spec.lane, expectedCommit, head, waitedSeconds: Math.round((Date.now() - started) / 1000) });
await sleep(5_000);
}
return { ok: false, sourceCommit: head, expectedCommit, matched: false, waitedSeconds: Math.round((Date.now() - started) / 1000), degradedReason: `${spec.lane}-source-head-not-aligned-after-merge` };
}
export async function waitForV02Cd(sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> {
const started = Date.now();
const startedAt = new Date(started).toISOString();
const pipelineRun = v02PipelineRunName(sourceCommit);
let lastStatus: Record<string, unknown> = {};
let firstPassedAt: string | null = null;
while (Date.now() - started < timeoutSeconds * 1000) {
lastStatus = v02ControlPlaneStatus({ sourceCommit, mode: "source-commit" });
const summary = summarizeV02CdStatus(lastStatus);
printEvent("v02.cd.status", { sourceCommit, pipelineRun, summary });
printV02PrMonitorProgress({ stage: "cd-status", status: "running", sourceCommit, pipelineRun, targetValidationState: summary.targetValidationState ?? null, pipelineStatus: summary.pipelineStatus ?? null, pendingFlush: summary.pendingFlush ?? null });
if (v02PipelineSucceeded(lastStatus) && summary.pendingFlush === true) {
const flush = flushV02GitMirrorIfNeeded(lastStatus, Math.min(timeoutSeconds, 600));
if (record(flush).ok !== true) {
return {
ok: false,
phase: "git-mirror-flush",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summary,
rawStatus: lastStatus,
flush,
};
}
const finalStatus = v02ControlPlaneStatus({ sourceCommit, mode: "source-commit" });
const finalSummary = summarizeV02CdStatus(finalStatus);
if (v02CdPassed(finalStatus)) {
printV02PrMonitorProgress({ stage: "git-mirror-flush", status: "succeeded", sourceCommit, pipelineRun, pendingFlush: finalSummary.pendingFlush ?? null });
return {
ok: true,
phase: "cd-passed",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: finalSummary,
rawStatus: finalStatus,
flush,
};
}
lastStatus = finalStatus;
printEvent("v02.cd.after-flush", { sourceCommit, pipelineRun, flushOk: record(flush).ok, summary: finalSummary });
printV02PrMonitorProgress({ stage: "git-mirror-flush", status: record(flush).ok === true ? "succeeded" : "failed", sourceCommit, pipelineRun, pendingFlush: finalSummary.pendingFlush ?? null });
}
if (v02CdPassed(lastStatus)) {
firstPassedAt ??= new Date().toISOString();
const flush = flushV02GitMirrorIfNeeded(lastStatus, Math.min(timeoutSeconds, 600));
const finalStatus = v02ControlPlaneStatus({ sourceCommit, mode: "source-commit" });
printV02PrMonitorProgress({ stage: "cd-status", status: v02CdPassed(finalStatus) ? "succeeded" : "failed", sourceCommit, pipelineRun, targetValidationState: nested(finalStatus, ["targetValidation", "state"]) ?? null });
return {
ok: v02CdPassed(finalStatus),
phase: v02CdPassed(finalStatus) ? "cd-passed" : "git-mirror-flush",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summarizeV02CdStatus(finalStatus),
rawStatus: finalStatus,
flush,
};
}
if (v02CdFailed(lastStatus)) {
printV02PrMonitorProgress({ stage: "cd-status", status: "failed", sourceCommit, pipelineRun, pipelineStatus: summary.pipelineStatus ?? null, pipelineReason: summary.pipelineReason ?? null });
return {
ok: false,
phase: "cd-failed",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summary,
rawStatus: lastStatus,
};
}
await sleep(30_000);
}
return {
ok: false,
phase: "timeout",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
timeoutSeconds,
firstPassedAt,
status: summarizeV02CdStatus(lastStatus),
rawStatus: lastStatus,
};
}
export async function waitForRuntimeLaneCd(spec: HwlabRuntimeLaneSpec, sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> {
const started = Date.now();
const startedAt = new Date(started).toISOString();
let pipelineRun = runtimeLanePipelineRunName(spec, sourceCommit);
let lastStatus: Record<string, unknown> = {};
let firstPassedAt: string | null = null;
while (Date.now() - started < timeoutSeconds * 1000) {
lastStatus = runtimeLaneStatusWithGitMirror(spec, sourceCommit);
const summary = summarizeRuntimeLaneCdStatus(spec, lastStatus);
pipelineRun = stringOrNull(summary.pipelineRun) ?? pipelineRun;
printEvent(`${spec.lane}.cd.status`, { lane: spec.lane, sourceCommit, pipelineRun, summary });
printRuntimeLanePrMonitorProgress(spec, {
stage: "cd-status",
status: "running",
sourceCommit,
pipelineRun,
pipelineStatus: summary.pipelineStatus ?? null,
pipelineReason: summary.pipelineReason ?? null,
argoSync: summary.argoSync ?? null,
argoHealth: summary.argoHealth ?? null,
publicProbesOk: summary.publicProbesOk ?? null,
pendingFlush: summary.pendingFlush ?? null,
});
if (runtimeLaneCdPassed(lastStatus)) {
firstPassedAt ??= new Date().toISOString();
const flush = flushRuntimeLaneGitMirrorIfNeeded(spec, lastStatus, Math.min(timeoutSeconds, 600));
if (record(flush).ok !== true) {
return {
ok: false,
phase: "git-mirror-flush",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summary,
rawStatus: lastStatus,
flush,
};
}
const finalStatus = runtimeLaneStatusWithGitMirror(spec, sourceCommit);
const finalSummary = summarizeRuntimeLaneCdStatus(spec, finalStatus);
printRuntimeLanePrMonitorProgress(spec, {
stage: "git-mirror-flush",
status: runtimeLaneCdPassedWithMirror(spec, finalStatus) ? "succeeded" : "failed",
sourceCommit,
pipelineRun,
pendingFlush: finalSummary.pendingFlush ?? null,
});
return {
ok: runtimeLaneCdPassedWithMirror(spec, finalStatus),
phase: runtimeLaneCdPassedWithMirror(spec, finalStatus) ? "cd-passed" : "git-mirror-flush",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: finalSummary,
rawStatus: finalStatus,
flush,
};
}
if (runtimeLaneCdFailed(lastStatus)) {
printRuntimeLanePrMonitorProgress(spec, { stage: "cd-status", status: "failed", sourceCommit, pipelineRun, pipelineStatus: summary.pipelineStatus ?? null, pipelineReason: summary.pipelineReason ?? null });
return {
ok: false,
phase: "cd-failed",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summary,
rawStatus: lastStatus,
};
}
await sleep(30_000);
}
return {
ok: false,
phase: "timeout",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
timeoutSeconds,
firstPassedAt,
status: summarizeRuntimeLaneCdStatus(spec, lastStatus),
rawStatus: lastStatus,
};
}
export async function runV02PrAutoCd(pr: OpenPullRequest, preflight: Record<string, unknown>, merge: CommandJsonResult, options: G14MonitorOptions, startedAt: string): Promise<Record<string, unknown>> {
const mergeRaceState = isCommandSuccess(merge) ? null : mergeCommandRaceState(merge);
if (!isCommandSuccess(merge) && mergeRaceState !== "merged") {
printV02PrMonitorProgress({ stage: "merge", status: "failed", pr: pr.number, mergeRaceState });
const comment = commentV02PullRequest({
pr,
phase: "merge",
state: "merge-failed",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: compactCommandResult(merge),
dryRun: options.dryRun,
message: mergeRaceState === "closed"
? "PR preflight 曾通过,但自动合并前 PR 已被关闭且未确认 merged;本轮不会触发 CD。"
: "PR preflight 已通过,但自动合并失败;需要处理 GitHub 返回的 merge blocker 后重试。",
});
return { ok: mergeRaceState === "closed", phase: "merge", action: "merge-race-closed", pr, merge, comment };
}
const expectedCommit = options.dryRun ? null : sourceCommitFromMergeResult(merge, pr.number);
if (!options.dryRun && expectedCommit === null) {
printV02PrMonitorProgress({ stage: "source-head", status: "failed", pr: pr.number, sourceCommit: null, pipelineRun: null, degradedReason: "merge-commit-unresolved" });
const comment = commentV02PullRequest({
pr,
phase: "merge-commit",
state: "cd-failed",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit: null,
pipelineRun: null,
dryRun: false,
message: "PR 已合并,但 UniDesk 未能从 GitHub merge/read 结果解析 merge commit;为避免触发错误 source head,本轮未启动 CD。",
});
return { ok: false, phase: "merge-commit", pr, merge: commandData(merge), comment, degradedReason: "merge-commit-unresolved" };
}
const headWait = options.dryRun ? { ok: true, sourceCommit: null, expectedCommit, matched: true } : await waitForV02Head(expectedCommit, 120);
const sourceCommit = typeof record(headWait).sourceCommit === "string" ? String(record(headWait).sourceCommit) : null;
const pipelineRun = sourceCommit === null ? null : v02PipelineRunName(sourceCommit);
printV02PrMonitorProgress({ stage: "merge", status: "succeeded", pr: pr.number, sourceCommit, pipelineRun, mergeRaceState });
const startedComment = commentV02PullRequest({
pr,
phase: options.dryRun ? "dry-run" : "cd-trigger",
state: "cd-started",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
dryRun: options.dryRun,
message: options.dryRun
? "dry-run:PR 已达到自动合并条件;本轮不会写 GitHub merge 或 CD。"
: mergeRaceState === "merged"
? "PR 在本轮 merge 前已被合并;worker 继续对齐 merge commit 并驱动 v0.2 CD。后续成功、失败或超时会继续在本 PR 下回复。"
: "PR 已自动合并,v0.2 CD 准备开始;其他 commit 的运行中 PipelineRun 不会阻塞本轮 CI,旧 run 若发现 source head 已推进会以 superseded/no-op 收口。后续成功、superseded、失败或超时会继续在本 PR 下回复。",
});
if (!options.dryRun && record(startedComment).ok !== true) {
return { ok: false, phase: "pr-comment", pr, sourceCommit, pipelineRun, comment: startedComment };
}
if (options.dryRun) return { ok: true, action: "dry-run-merge", pr, merge: commandData(merge), comment: startedComment };
if (sourceCommit === null || record(headWait).ok !== true) {
printV02PrMonitorProgress({ stage: "source-head", status: "failed", pr: pr.number, sourceCommit, pipelineRun, expectedCommit });
const comment = commentV02PullRequest({
pr,
phase: "v02-source-head",
state: "cd-timeout",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
dryRun: false,
message: "PR 已合并,但 G14 v0.2 CI/CD source repo 没有在等待窗口内对齐 merge commit;为避免触发旧 head,本轮未启动 CD。",
});
return { ok: false, phase: "v02-head", pr, merge: commandData(merge), expectedCommit, headWait, comment: record(comment).ok === true ? comment : startedComment, degradedReason: "v02-head-unresolved-after-merge" };
}
const before = v02ControlPlaneStatus({ sourceCommit, mode: "source-commit" });
const beforeSummary = summarizeV02CdStatus(before);
const activeRuns = activeV02PipelineRuns(before);
if (activeRuns.length > 0) {
printEvent("v02.cd.active-runs-observed", { pr: pr.number, sourceCommit, pipelineRun, activeCount: activeRuns.length, activeRuns: activeRuns.slice(0, 5), latestOnlyPolicy: "do-not-wait-or-cancel-old-runs" });
}
const trigger = v02CdPassed(before) ? { ok: true, skipped: true, reason: "source-commit-already-deployed" } : triggerV02Current(Math.min(options.timeoutSeconds, 600));
printEvent("v02.cd.trigger", { pr: pr.number, sourceCommit, pipelineRun, ok: record(trigger).ok, skipped: record(trigger).skipped ?? false, degradedReason: record(trigger).degradedReason ?? null });
printV02PrMonitorProgress({ stage: "cd-trigger", status: record(trigger).ok === true ? "succeeded" : "failed", pr: pr.number, sourceCommit, pipelineRun, activeCount: activeRuns.length, skipped: record(trigger).skipped ?? false, degradedReason: record(trigger).degradedReason ?? null });
if (record(trigger).ok !== true) {
const comment = commentV02PullRequest({
pr,
phase: "cd-trigger",
state: "cd-failed",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
cd: beforeSummary,
dryRun: false,
message: "PR 已合并,但 v0.2 CD 触发失败;需要查看 trigger 阶段的 degradedReason。",
});
return { ok: false, phase: "cd-trigger", pr, sourceCommit, pipelineRun, trigger, comment };
}
const cd = await waitForV02Cd(sourceCommit, options.timeoutSeconds);
const cdStatus = record(cd.status);
const flush = record(cd.flush);
const cdOk = cd.ok === true;
const cdPhase = String(cd.phase ?? "");
const cdSuperseded = cdOk && cdStatus.targetValidationState === "superseded";
const finalComment = commentV02PullRequest({
pr,
phase: cdPhase,
state: cdOk ? (cdSuperseded ? "cd-superseded" : "cd-succeeded") : cdPhase === "timeout" ? "cd-timeout" : "cd-failed",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
cd: cdStatus,
flush,
dryRun: false,
message: cdOk
? cdSuperseded
? "v0.2 自动 CD 已收口:本 merge head 的 PipelineRun 已完成,但 source head 已被后续提交推进,本轮按 latest-only 语义 superseded/no-op,未回写旧 GitOps revision。"
: "v0.2 自动 CD 已完成:PipelineRun、Argo/runtime、公开探针和 Git mirror flush 收口均已检查。"
: cdPhase === "timeout"
? "v0.2 自动 CD 超时未收口;本评论保留最后一次 targetValidation / PipelineRun / Git mirror 状态用于接续排障。"
: "v0.2 自动 CD 失败;本评论保留失败阶段和最后一次状态用于接续排障。",
});
return {
ok: cdOk && record(finalComment).ok === true,
action: cdOk ? (cdSuperseded ? "merged-and-superseded-v02" : "merged-and-rolled-v02") : "v02-cd-failed",
phase: cdOk ? "cd-passed" : cdPhase,
pr,
sourceCommit,
pipelineRun,
trigger,
cd,
comment: finalComment,
};
}
export async function runRuntimeLanePrAutoCd(spec: HwlabRuntimeLaneSpec, pr: OpenPullRequest, preflight: Record<string, unknown>, merge: CommandJsonResult, options: G14MonitorOptions, startedAt: string): Promise<Record<string, unknown>> {
const mergeRaceState = isCommandSuccess(merge) ? null : mergeCommandRaceState(merge);
if (!isCommandSuccess(merge) && mergeRaceState !== "merged") {
printRuntimeLanePrMonitorProgress(spec, { stage: "merge", status: "failed", pr: pr.number, mergeRaceState });
const observedAt = new Date().toISOString();
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: "merge",
state: "merge-failed",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: compactCommandResult(merge),
dryRun: options.dryRun,
message: mergeRaceState === "closed"
? `PR preflight 曾通过,但自动合并前 PR 已被关闭且未确认 merged;本轮不会触发 ${spec.version} CD。`
: "PR preflight 已通过,但自动合并失败;需要处理 GitHub 返回的 merge blocker 后重试。",
});
const issue = reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "merge-failed",
phase: "merge",
startedAt,
observedAt,
preflight,
details: { merge: compactCommandResult(merge), mergeRaceState },
message: "自动合并失败,v0.3 monitor 已暂停 CD 触发。",
dryRun: options.dryRun,
});
return { ok: mergeRaceState === "closed" && record(comment).ok === true && record(issue).ok === true, phase: "merge", action: "merge-race-closed", pr, merge, comment, issue };
}
const expectedCommit = options.dryRun ? null : sourceCommitFromMergeResult(merge, pr.number);
if (!options.dryRun && expectedCommit === null) {
const observedAt = new Date().toISOString();
printRuntimeLanePrMonitorProgress(spec, { stage: "source-head", status: "failed", pr: pr.number, sourceCommit: null, pipelineRun: null, degradedReason: "merge-commit-unresolved" });
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: "merge-commit",
state: "cd-failed",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: commandData(merge),
sourceCommit: null,
pipelineRun: null,
dryRun: false,
message: "PR 已合并,但 UniDesk 未能从 GitHub merge/read 结果解析 merge commit;为避免触发错误 source head,本轮未启动 CD。",
});
const issue = reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "cd-failed",
phase: "merge-commit",
startedAt,
observedAt,
preflight,
details: { merge: commandData(merge) },
message: "PR 已合并但 merge commit 不可解析,v0.3 CD 未启动。",
dryRun: false,
});
return { ok: false, phase: "merge-commit", pr, merge: commandData(merge), comment, issue, degradedReason: "merge-commit-unresolved" };
}
const headWait = options.dryRun ? { ok: true, sourceCommit: null, expectedCommit, matched: true } : await waitForRuntimeLaneHead(spec, expectedCommit, 120);
const sourceCommit = typeof record(headWait).sourceCommit === "string" ? String(record(headWait).sourceCommit) : null;
const pipelineRun = sourceCommit === null ? null : runtimeLanePipelineRunName(spec, sourceCommit);
printRuntimeLanePrMonitorProgress(spec, { stage: "merge", status: "succeeded", pr: pr.number, sourceCommit, pipelineRun, mergeRaceState });
const startedComment = commentRuntimeLanePullRequest(spec, {
pr,
phase: options.dryRun ? "dry-run" : "cd-trigger",
state: "cd-started",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
dryRun: options.dryRun,
message: options.dryRun
? `dry-runPR 已达到 ${spec.version} 自动合并条件;本轮不会写 GitHub merge 或 CD。`
: mergeRaceState === "merged"
? `PR 在本轮 merge 前已被合并;worker 继续对齐 merge commit 并驱动 ${spec.version} CD。后续成功、失败或超时会继续在本 PR 下回复。`
: `PR 已自动合并,${spec.version} CD 准备开始;后续成功、失败或超时会继续在本 PR 下回复。`,
});
if (!options.dryRun && record(startedComment).ok !== true) {
return { ok: false, phase: "pr-comment", pr, sourceCommit, pipelineRun, comment: startedComment };
}
if (options.dryRun) return { ok: true, action: "dry-run-merge", pr, merge: commandData(merge), comment: startedComment };
if (sourceCommit === null || record(headWait).ok !== true) {
const observedAt = new Date().toISOString();
printRuntimeLanePrMonitorProgress(spec, { stage: "source-head", status: "failed", pr: pr.number, sourceCommit, pipelineRun, expectedCommit });
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: `${spec.lane}-source-head`,
state: "cd-timeout",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
dryRun: false,
message: `PR 已合并,但 G14 ${spec.version} CI/CD source repo 没有在等待窗口内对齐 merge commit;为避免触发旧 head,本轮未启动 CD。`,
});
const issue = reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "cd-timeout",
phase: `${spec.lane}-source-head`,
startedAt,
observedAt,
preflight,
sourceCommit,
pipelineRun,
details: { expectedCommit, headWait },
message: `${spec.version} source head 未在等待窗口内对齐 merge commitCD 未启动。`,
dryRun: false,
});
return { ok: false, phase: `${spec.lane}-head`, pr, merge: commandData(merge), expectedCommit, headWait, comment: record(comment).ok === true ? comment : startedComment, issue, degradedReason: `${spec.lane}-head-unresolved-after-merge` };
}
const before = runtimeLaneStatusWithGitMirror(spec, sourceCommit);
const beforeSummary = summarizeRuntimeLaneCdStatus(spec, before);
const trigger = runtimeLaneCdPassedWithMirror(spec, before) ? { ok: true, skipped: true, reason: "source-commit-already-deployed" } : triggerRuntimeLaneCurrent(spec, Math.min(options.timeoutSeconds, 600));
printEvent(`${spec.lane}.cd.trigger`, { pr: pr.number, sourceCommit, pipelineRun, ok: record(trigger).ok, skipped: record(trigger).skipped ?? false, degradedReason: record(trigger).degradedReason ?? null });
printRuntimeLanePrMonitorProgress(spec, { stage: "cd-trigger", status: record(trigger).ok === true ? "succeeded" : "failed", pr: pr.number, sourceCommit, pipelineRun, skipped: record(trigger).skipped ?? false, degradedReason: record(trigger).degradedReason ?? null });
if (record(trigger).ok !== true) {
const observedAt = new Date().toISOString();
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: "cd-trigger",
state: "cd-failed",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
cd: beforeSummary,
dryRun: false,
message: `PR 已合并,但 ${spec.version} CD 触发失败;需要查看 trigger 阶段的 degradedReason。`,
});
const issue = reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "cd-failed",
phase: "cd-trigger",
startedAt,
observedAt,
preflight,
sourceCommit,
pipelineRun,
cd: beforeSummary,
details: { trigger },
message: `${spec.version} CD 触发失败,PipelineRun 未进入可等待状态。`,
dryRun: false,
});
return { ok: false, phase: "cd-trigger", pr, sourceCommit, pipelineRun, trigger, comment, issue };
}
const cd = await waitForRuntimeLaneCd(spec, sourceCommit, options.timeoutSeconds);
const cdStatus = record(cd.status);
const flush = record(cd.flush);
const cdOk = cd.ok === true;
const cdPhase = String(cd.phase ?? "");
const cdPipelineRun = stringOrNull(cdStatus.pipelineRun) ?? pipelineRun;
const observedAt = new Date().toISOString();
const finalComment = commentRuntimeLanePullRequest(spec, {
pr,
phase: cdPhase,
state: cdOk ? "cd-succeeded" : cdPhase === "timeout" ? "cd-timeout" : "cd-failed",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun: cdPipelineRun,
cd: cdStatus,
flush,
dryRun: false,
message: cdOk
? `${spec.version} 自动 CD 已完成:PipelineRun、Argo/runtime、公开探针和 Git mirror flush 收口均已检查。`
: cdPhase === "timeout"
? `${spec.version} 自动 CD 超时未收口;本评论保留最后一次 PipelineRun / Argo / public probe / Git mirror 状态用于接续排障。`
: `${spec.version} 自动 CD 失败;本评论保留失败阶段和最后一次状态用于接续排障。`,
});
const issue = cdOk ? { ok: true, skipped: true, reason: "cd-succeeded" } : reportRuntimeLaneAutomationIssue({
spec,
pr,
state: cdPhase === "timeout" ? "cd-timeout" : "cd-failed",
phase: cdPhase || "cd",
startedAt,
observedAt,
preflight,
sourceCommit,
pipelineRun: cdPipelineRun,
cd: cdStatus,
details: { cd },
message: cdPhase === "timeout" ? `${spec.version} 自动 CD 超时。` : `${spec.version} 自动 CD 失败。`,
dryRun: false,
});
return {
ok: cdOk && record(finalComment).ok === true && record(issue).ok === true,
action: cdOk ? `merged-and-rolled-${spec.lane}` : `${spec.lane}-cd-failed`,
phase: cdOk ? "cd-passed" : cdPhase,
pr,
sourceCommit,
pipelineRun: cdPipelineRun,
trigger,
cd,
comment: finalComment,
issue,
};
}