fix(cicd): reject empty release triggers

This commit is contained in:
pikastech
2026-07-21 13:12:32 +02:00
parent c51aac150a
commit 9468cf9eb6
3 changed files with 35 additions and 13 deletions
@@ -331,29 +331,38 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook",
expect(pac.repositories.find((repository) => repository.id === "hwlab-nc01-v03")?.params.gitops_read_url)
.toBe("http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git");
expect(triggerAction).toContain("release-plan-scope-expanded");
expect(triggerAction).toContain("release-plan-empty-scope");
expect(triggerAction).not.toContain("kubectl create");
expect(triggerAction).not.toContain("PipelineRun");
});
test("PaC release scoped help stays on the manual plan-before-trigger path", async () => {
const root = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["--help"]);
const release = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["release", "--help"]);
const plan = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["release", "plan", "--help"]);
const trigger = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["release", "trigger", "--help"]);
expect(release).toMatchObject({ command: "platform-infra pipelines-as-code release", mutation: false });
expect(JSON.stringify(root)).toContain("范围非空");
expect(JSON.stringify(release)).toContain("scopeReview.status=exact");
expect(JSON.stringify(release)).toContain("终态 no-op");
expect(JSON.stringify(plan)).toContain("release plan --target <NODE>");
expect(JSON.stringify(plan)).toContain("envReuse");
expect(JSON.stringify(plan)).toContain("计划非空");
expect(JSON.stringify(trigger)).toContain("release trigger --target <NODE>");
expect(JSON.stringify(trigger)).toContain("--confirm");
expect(JSON.stringify(trigger)).toContain("先执行 release plan");
expect(JSON.stringify(trigger)).toContain("无需 trigger");
});
test("PaC failed or expanded plan never points next to trigger", () => {
const args = ["plan", "NC01", "hwlab-nc01-v03", "a".repeat(40), "b".repeat(40)] as const;
expect(manualReleaseNext(...args, { planSucceeded: false, scopeExpanded: false, remoteError: false })).toContain("then rerun");
expect(manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: true, remoteError: false })).toContain("then rerun");
expect(manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, remoteError: false })).toContain("release trigger");
const observed = manualReleaseNext("trigger", args[1], args[2], args[3], args[4], { planSucceeded: true, scopeExpanded: false, remoteError: false });
expect(manualReleaseNext(...args, { planSucceeded: false, scopeExpanded: false, scopeEmpty: false, remoteError: false })).toContain("then rerun");
expect(manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: true, scopeEmpty: false, remoteError: false })).toContain("then rerun");
expect(manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, remoteError: false })).toContain("release trigger");
const noOp = manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, scopeEmpty: true, remoteError: false });
expect(noOp).toContain("no release trigger required");
expect(noOp).not.toContain("release trigger --target");
const observed = manualReleaseNext("trigger", args[1], args[2], args[3], args[4], { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, remoteError: false });
expect(observed).toContain("delivery-observe");
expect(observed).toContain("a".repeat(40));
expect(observed).not.toContain("closeout");
@@ -342,17 +342,24 @@ manual_release_action() {
fi
plan_file=$(mktemp)
printf '%s' "$plan" >"$plan_file"
scope_expanded=$(node - "$plan_file" <<'NODE'
plan_gate=$(node - "$plan_file" <<'NODE'
const fs = require('node:fs');
const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
process.stdout.write(plan.scopeReview?.expanded === true ? '1' : '0');
if (plan.scopeReview?.expanded === true) process.stdout.write('expanded');
else if (Number(plan.imageBuildCount || 0) === 0 && Number(plan.rolloutServiceCount || 0) === 0 && Number(plan.affectedServiceCount || 0) === 0) process.stdout.write('empty');
else process.stdout.write('ready');
NODE
)
if [ "$scope_expanded" = "1" ]; then
if [ "$plan_gate" = "expanded" ]; then
rm -f "$plan_file"
printf '{"ok":false,"mutation":false,"error":"release-plan-scope-expanded","plan":%s,"webhook":null,"valuesPrinted":false}\n' "$plan"
return 1
fi
if [ "$plan_gate" = "empty" ]; then
rm -f "$plan_file"
printf '{"ok":false,"mutation":false,"error":"release-plan-empty-scope","plan":%s,"webhook":null,"valuesPrinted":false}\n' "$plan"
return 1
fi
payload_file=$(mktemp)
node - "$plan_file" >"$payload_file" <<'NODE'
const fs = require('node:fs');
@@ -578,11 +578,11 @@ function help(scope: string | null): Record<string, unknown> {
command: `platform-infra pipelines-as-code release${action === null ? "" : ` ${action}`}`,
configTruth: configLabel,
usage: action === "plan" ? [planUsage] : action === "trigger" ? [triggerUsage] : [planUsage, triggerUsage],
boundary: "PR merge 只更新 source authority,不创建 PipelineRun。必须先 plan;仅当 scopeReview.status=exact 时才可 trigger。trigger 只发送 PaC webhook,由 PaC 创建 PipelineRun。",
boundary: "PR merge 只更新 source authority,不创建 PipelineRun。必须先 plan;仅当 scopeReview.status=exact 且 imageBuildCount、rolloutServiceCount、affectedServiceCount 至少一项非零时才可 trigger。三项均为零是终态 no-op,不发送 webhook。trigger 只发送 PaC webhook,由 PaC 创建 PipelineRun。",
review: ["requestedScope", "effective", "envReuse", "imageBuildCount", "rolloutServiceCount", "affectedServiceCount", "scopeReview"],
next: action === "trigger"
? "先执行 release plan,并确认 scopeReview.status=exact;范围扩大时修复 source range、planner、源码或 owning YAML 后重新 plan。"
: "先执行 release plan;仅在 scopeReview.status=exact 时执行 release trigger --confirm。",
? "先执行 release plan,并确认 scopeReview.status=exact 且计划非空;范围扩大时修复 source range、planner、源码或 owning YAML 后重新 plan,三项范围计数均为零时无需 trigger。"
: "先执行 release plan;仅在 scopeReview.status=exact 且计划非空时执行 release trigger --confirm,三项范围计数均为零时无需 trigger。",
mutation: action === "trigger" ? "explicit-confirm-required" : false,
valuesPrinted: false,
};
@@ -636,7 +636,7 @@ function help(scope: string | null): Record<string, unknown> {
],
maintenanceHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
compatibilityHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
boundary: "唯一正式发布链:GitHub PR merge -> Gitea source authority -> CLI release plan -> CLI 手动 PaC webhook -> Tekton -> GitOps/Argo/k8s runtime。",
boundary: "唯一正式发布链:GitHub PR merge -> Gitea source authority -> CLI release plan -> CLI 手动 PaC webhook -> Tekton -> GitOps/Argo/k8s runtime。仅 scopeReview.status=exact 且范围非空的 plan 可发送 webhookbuild、rollout、affected 均为零时以 no-op 结束。",
};
}
@@ -1639,6 +1639,10 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger",
const remote = parseJsonOutput(result.stdout) ?? {};
const fullReleasePlan = remote.plan && typeof remote.plan === "object" ? remote.plan as Record<string, any> : null;
const scopeExpanded = fullReleasePlan?.scopeReview?.expanded === true;
const scopeEmpty = fullReleasePlan !== null
&& Number(fullReleasePlan.imageBuildCount ?? 0) === 0
&& Number(fullReleasePlan.rolloutServiceCount ?? 0) === 0
&& Number(fullReleasePlan.affectedServiceCount ?? 0) === 0;
const planSucceeded = result.exitCode === 0 && remote.ok === true && fullReleasePlan !== null;
const releasePlan = fullReleasePlan === null || options.full || options.raw
? fullReleasePlan
@@ -1658,6 +1662,7 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger",
next: manualReleaseNext(action, target.id, consumer.id, options.sourceCommit, options.baseCommit, {
planSucceeded,
scopeExpanded,
scopeEmpty,
remoteError: typeof remote.error === "string",
}),
valuesPrinted: false,
@@ -1696,10 +1701,11 @@ export function manualReleaseNext(
consumerId: string,
sourceCommit: string,
baseCommit: string | null,
outcome: { readonly planSucceeded: boolean; readonly scopeExpanded: boolean; readonly remoteError: boolean },
outcome: { readonly planSucceeded: boolean; readonly scopeExpanded: boolean; readonly scopeEmpty: boolean; readonly remoteError: boolean },
): string {
const baseSuffix = baseCommit === null ? "" : ` --base-commit ${baseCommit}`;
const plan = `bun scripts/cli.ts platform-infra pipelines-as-code release plan --target ${targetId} --consumer ${consumerId} --source-commit ${sourceCommit}${baseSuffix}`;
if (outcome.scopeEmpty) return "no release trigger required: plan has zero build, rollout, and affected services";
if (action === "plan") {
return outcome.planSucceeded && !outcome.scopeExpanded
? `bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target ${targetId} --consumer ${consumerId} --source-commit ${sourceCommit}${baseSuffix} --confirm`
@@ -1789,7 +1795,7 @@ function bootstrapResult(
next: options.confirm
? {
sourceMerge: `Merge the prepared GitHub PR for ${mirror.upstream.repository}@${mirror.upstream.branch}; merge only updates source authority and does not create a PipelineRun.`,
releasePlan: `After merge, run bun scripts/cli.ts platform-infra pipelines-as-code release plan --target ${target.id} --consumer ${consumer.id} --source-commit <full-sha>; run release trigger --confirm only when scopeReview.status is exact.`,
releasePlan: `After merge, run bun scripts/cli.ts platform-infra pipelines-as-code release plan --target ${target.id} --consumer ${consumer.id} --source-commit <full-sha>; run release trigger --confirm only when scopeReview.status is exact and the plan has at least one build, rollout, or affected service. Zero scope is a terminal no-op.`,
investigate: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${target.id} --consumer ${consumer.id}`,
}
: {