From 9468cf9eb6cf427e7025d082c111126087904f9e Mon Sep 17 00:00:00 2001 From: pikastech Date: Tue, 21 Jul 2026 13:12:32 +0200 Subject: [PATCH] fix(cicd): reject empty release triggers --- ...m-infra-pipelines-as-code-bootstrap.test.ts | 17 +++++++++++++---- .../platform-infra-pipelines-as-code-remote.sh | 13 ++++++++++--- .../src/platform-infra-pipelines-as-code.ts | 18 ++++++++++++------ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index 40369310..1cf7d894 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -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 "); expect(JSON.stringify(plan)).toContain("envReuse"); + expect(JSON.stringify(plan)).toContain("计划非空"); expect(JSON.stringify(trigger)).toContain("release trigger --target "); 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"); diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 4923dd6b..21023d2e 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -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'); diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 36fed9ed..7d9a6a65 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -578,11 +578,11 @@ function help(scope: string | null): Record { 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 { ], 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 : 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 ; 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 ; 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}`, } : {