fix(cicd): reject empty release triggers
This commit is contained in:
@@ -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 可发送 webhook;build、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}`,
|
||||
}
|
||||
: {
|
||||
|
||||
Reference in New Issue
Block a user