diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index e26354a0..bba50a85 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -69,6 +69,8 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - PR merge、push 和 branch update 只更新 source authority,不创建 PipelineRun; - 先运行 `release plan`,检查本次请求范围、累计有效范围、env reuse、待构建镜像数量与列表、rollout 范围和全部受影响服务; - 当前 GitOps catalog 落后 source 时,plan 必须把累计发布欠账与本次 base 到 source 的请求范围分开显示; + - HWLAB `release plan` 省略 `--base-commit` 时必须先恢复 catalog,并使用 `catalogSourceCommitId` 作为范围基线;禁止回退到 source parent; + - 显式 `--base-commit` 只用于诊断请求范围;输出必须披露 `rangeBaseline.catalogAligned`,expanded 的动态 `next` 必须直接给出 catalog-aligned replan; - 有效范围大于请求范围时标记 `expanded`,`release trigger` 必须在发送 webhook 前拒绝; - 计划范围扩大时先修复 source range、源码、YAML 或项目 planner,再重新 plan; - `package.json` 即使同时出现在 runtime deps 与 component paths,也只按运行字段做一次语义判定;scripts-only 变化不得进入服务 changed paths; diff --git a/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md b/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md index 083a6c2b..dc0a8062 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md +++ b/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md @@ -152,6 +152,11 @@ L2 与 L3 发布必须使用 `plan -> 人工审阅 -> trigger` 的受控手动 - 与 Argo 和 promotion 使用同一 GitOps authority; - Source mirror 只用于读取 source commit,不得代替 GitOps authority; - catalog 无法读取、来源不一致或落后于已部署 revision 时,停止触发并返回重新 plan 的只读引导。 +- 发布范围基线必须来自 catalog source: + - 调用方省略 `base commit` 时,必须使用恢复出的 `catalogSourceCommitId`; + - 禁止静默回退到 `source commit` 的 parent; + - 显式 `base commit` 只覆盖请求范围诊断,输出必须同时披露 catalog source 和两者是否对齐; + - 缺少有效 catalog source 时必须保持只读并 fail closed,不得猜测 parent 或创建 PipelineRun。 范围相对 owning YAML 声明的发布意图扩大时,`plan` 必须明确列出新增项。操作者应先修正源码、配置或 planner,使范围回到预期后重新生成计划;不得通过确认参数、白名单、忽略 warning 或扩大资源预算继续触发。 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 21501c65..3fb3a4be 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -323,6 +323,9 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook", expect(planAction).toContain("requestedScope"); expect(planAction).toContain("service.envChangedPaths"); expect(planAction).toContain("service.codeChangedPaths"); + expect(planAction).toContain("rangeBaseline"); + expect(planAction).toContain("base_source=catalog-source"); + expect(planAction.indexOf("restore-artifact-catalog.mjs")).toBeLessThan(planAction.indexOf("base_source=catalog-source")); expect(planAction).toContain("expandedServices"); expect(planAction).toContain("buildServices"); expect(planAction).toContain("rolloutServices"); @@ -347,6 +350,7 @@ test("PaC release scoped help stays on the manual plan-before-trigger path", asy expect(JSON.stringify(root)).toContain("范围非空"); expect(JSON.stringify(release)).toContain("scopeReview.status=exact"); expect(JSON.stringify(release)).toContain("终态 no-op"); + expect(JSON.stringify(release)).toContain("rangeBaseline.catalogAligned=true"); expect(JSON.stringify(plan)).toContain("release plan --target "); expect(JSON.stringify(plan)).toContain("envReuse"); expect(JSON.stringify(plan)).toContain("计划非空"); @@ -358,13 +362,16 @@ test("PaC release scoped help stays on the manual plan-before-trigger path", asy 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, 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(manualReleaseNext(...args, { planSucceeded: false, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, remoteError: false })).toContain("then rerun"); + const catalogBase = "c".repeat(40); + const expanded = manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: true, scopeEmpty: false, catalogBaseCommit: catalogBase, remoteError: false }); + expect(expanded).toContain(`--base-commit ${catalogBase}`); + expect(expanded).not.toContain(`--base-commit ${args[4]}`); + expect(manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, remoteError: false })).toContain("release trigger"); + const noOp = manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, scopeEmpty: true, catalogBaseCommit: null, 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 }); + const observed = manualReleaseNext("trigger", args[1], args[2], args[3], args[4], { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, 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 6b3ee6f6..625918e3 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -247,10 +247,7 @@ manual_release_plan_json() { source_commit=$(git -C "$tmp/source" rev-parse HEAD) [ "$source_commit" = "$UNIDESK_PAC_MANUAL_SOURCE_COMMIT" ] base_commit=${UNIDESK_PAC_MANUAL_BASE_COMMIT:-} - if [ -z "$base_commit" ]; then base_commit=$(git -C "$tmp/source" rev-parse "$source_commit^"); fi - git -C "$tmp/source" cat-file -e "$base_commit^{commit}" - changed_paths_file="$tmp/changed-paths.txt" - git -C "$tmp/source" diff --name-only "$base_commit" "$source_commit" >"$changed_paths_file" + base_source=explicit domain_plan_file="$tmp/domain-plan.json" if [ "$UNIDESK_PAC_MANUAL_RENDERER" = "hwlab-runtime-lane" ]; then if [ ! -d "$UNIDESK_PAC_MANUAL_NODE_MODULES" ]; then @@ -267,6 +264,24 @@ manual_release_plan_json() { HWLAB_SERVICES="$UNIDESK_PAC_MANUAL_SERVICE_IDS" \ node scripts/ci/restore-artifact-catalog.mjs ) + if [ -z "$base_commit" ]; then + base_commit=$(node - "$tmp/source/$UNIDESK_PAC_MANUAL_ARTIFACT_CATALOG_PATH.authority.json" <<'NODE' +const fs = require('node:fs'); +const path = process.argv[2]; +const authority = JSON.parse(fs.readFileSync(path, 'utf8')); +const commit = String(authority.catalogSourceCommitId || ''); +if (!/^[0-9a-f]{40}$/u.test(commit)) { + process.stderr.write('HWLAB release plan requires a valid catalogSourceCommitId\n'); + process.exit(1); +} +process.stdout.write(commit); +NODE +) + base_source=catalog-source + fi + git -C "$tmp/source" cat-file -e "$base_commit^{commit}" + changed_paths_file="$tmp/changed-paths.txt" + git -C "$tmp/source" diff --name-only "$base_commit" "$source_commit" >"$changed_paths_file" (cd "$tmp/source" && node scripts/ci-plan.mjs \ --lane "$UNIDESK_PAC_CONSUMER_LANE" \ --base-ref "$base_commit" \ @@ -276,9 +291,16 @@ manual_release_plan_json() { --registry-prefix "$UNIDESK_PAC_MANUAL_REGISTRY_PREFIX" \ --base-image "$UNIDESK_PAC_MANUAL_BASE_IMAGE") >"$domain_plan_file" else + if [ -z "$base_commit" ]; then + base_commit=$(git -C "$tmp/source" rev-parse "$source_commit^") + base_source=source-parent + fi + git -C "$tmp/source" cat-file -e "$base_commit^{commit}" + changed_paths_file="$tmp/changed-paths.txt" + git -C "$tmp/source" diff --name-only "$base_commit" "$source_commit" >"$changed_paths_file" printf 'null' >"$domain_plan_file" fi - SOURCE_COMMIT="$source_commit" BASE_COMMIT="$base_commit" node - "$changed_paths_file" "$domain_plan_file" <<'NODE' + SOURCE_COMMIT="$source_commit" BASE_COMMIT="$base_commit" BASE_SOURCE="$base_source" node - "$changed_paths_file" "$domain_plan_file" <<'NODE' const fs = require('node:fs'); const changedPaths = fs.readFileSync(process.argv[2], 'utf8').split('\n').filter(Boolean); const domain = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); @@ -310,6 +332,14 @@ const plan = { changedPathCount: changedPaths.length, changedPaths, artifactCatalog: domain?.artifactCatalog || null, + rangeBaseline: { + mode: process.env.BASE_SOURCE, + baseCommit: process.env.BASE_COMMIT, + catalogSourceCommitId: domain?.artifactCatalog?.catalogSourceCommitId || null, + catalogAligned: domain?.artifactCatalog?.catalogSourceCommitId + ? domain.artifactCatalog.catalogSourceCommitId === process.env.BASE_COMMIT + : null, + }, requestedScope: { affectedServiceCount: requestedServices.length, affectedServices: requestedServices, diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 7d9a6a65..ef8bea66 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -579,10 +579,10 @@ function help(scope: string | null): Record { configTruth: configLabel, usage: action === "plan" ? [planUsage] : action === "trigger" ? [triggerUsage] : [planUsage, triggerUsage], 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"], + review: ["rangeBaseline", "requestedScope", "effective", "envReuse", "imageBuildCount", "rolloutServiceCount", "affectedServiceCount", "scopeReview"], next: action === "trigger" - ? "先执行 release plan,并确认 scopeReview.status=exact 且计划非空;范围扩大时修复 source range、planner、源码或 owning YAML 后重新 plan,三项范围计数均为零时无需 trigger。" - : "先执行 release plan;仅在 scopeReview.status=exact 且计划非空时执行 release trigger --confirm,三项范围计数均为零时无需 trigger。", + ? "先执行 release plan,并确认 rangeBaseline.catalogAligned=true、scopeReview.status=exact 且计划非空;省略 --base-commit 时 HWLAB 使用 catalog source,范围扩大时按 next 重新 plan,三项范围计数均为零时无需 trigger。" + : "先执行 release plan;HWLAB 默认以 catalog source 为范围基线,确认 rangeBaseline.catalogAligned=true、scopeReview.status=exact 且计划非空后才执行 release trigger --confirm,三项范围计数均为零时无需 trigger。", mutation: action === "trigger" ? "explicit-confirm-required" : false, valuesPrinted: false, }; @@ -1659,10 +1659,13 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger", releasePlan, webhook: action === "trigger" ? remote.webhook ?? null : null, remote: result.exitCode === 0 ? undefined : { exitCode: result.exitCode, stderrTail: result.stderr.slice(-1200) }, - next: manualReleaseNext(action, target.id, consumer.id, options.sourceCommit, options.baseCommit, { + next: manualReleaseNext(action, target.id, consumer.id, options.sourceCommit, typeof fullReleasePlan?.baseCommit === "string" ? fullReleasePlan.baseCommit : options.baseCommit, { planSucceeded, scopeExpanded, scopeEmpty, + catalogBaseCommit: typeof fullReleasePlan?.artifactCatalog?.catalogSourceCommitId === "string" + ? fullReleasePlan.artifactCatalog.catalogSourceCommitId + : null, remoteError: typeof remote.error === "string", }), valuesPrinted: false, @@ -1682,6 +1685,7 @@ function compactManualReleasePlan(plan: Record): Record