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 e8c31613..b871b799 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md +++ b/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md @@ -69,7 +69,7 @@ | 边界项 | 内容 | | --- | --- | | 外部使用者 | 平台管理员、发布操作人员、服务维护者和需要发布状态的业务模块。 | -| 外部输入 | source commit、deploy 配置、lane 选择、发布计划请求、带计划指纹的手动触发请求、SecretRef presence 和验证请求。 | +| 外部输入 | source commit、deploy 配置、lane 选择、发布计划请求、带 `plan identity` 的手动触发请求、SecretRef presence 和验证请求。 | | 受控资源 | Tekton PipelineRun、Argo Application、runtime namespace、workload、image、release status、health/readiness 和发布判定结果。 | | 外部输出 | PipelineRun 状态、image digest、promotion revision、Argo sync 状态、runtime readiness、发布候选结论和 redacted 失败信息。 | | 用户接口 | UniDesk CI/CD CLI、服务自有 CLI 发布/验证相关入口、服务 health/readiness、Tekton/Argo 受控状态查询入口。 | @@ -85,7 +85,7 @@ | PJ2026-01060102 | 镜像Promotion | 本规格 6.2 | env image reuse、digest-pinned image、artifact promotion 和 image readiness | Source commit、Containerfile、registry | 需要运行镜像的服务 | | PJ2026-01060103 | 发布判定 | 本规格 6.3 | source/GitOps/runtime 一致性、health/readiness 和 migration/SecretRef presence | CI/CD、GitOps、YAML运维 | 平台管理员、业务模块 | | PJ2026-01060104 | 验证分层 | 本规格 6.4 | 自测试、综合联调、CLI/API 交互和真实运行面通过口径 | 目标 runtime、业务模块测试需求 | 发布决策 | -| PJ2026-01060108 | 手动发布 | 本规格 6.5 | L2/L3 发布计划、范围审阅和带计划指纹的受控手动触发 | 源码同步、YAML运维、镜像Promotion | 全部 L2/L3 runtime 服务 | +| PJ2026-01060108 | 手动发布 | 本规格 6.5 | L2/L3 发布计划、范围审阅和带 plan identity 的受控手动触发 | 源码同步、YAML运维、镜像Promotion | 全部 L2/L3 runtime 服务 | | PJ2026-01060105 | AgentRun发布 | [PJ2026-01060105 AgentRun发布Lane](PJ2026-01060105-agentrun-v01-release-lane.md) | AgentRun `v0.1` 的发布 lane、Pipeline、runtime namespace 和真实联调细则 | 源码同步、YAML运维、Agent编排 | AgentRun runtime | | PJ2026-01060106 | HWLAB双环境 | [PJ2026-01060106 HWLAB双环境](PJ2026-01060106-hwlab-dev-production-lanes.md) | HWLAB `v0.3` development 与 `release` production 的分支、流水线、公开入口和数据隔离 | 源码同步、YAML运维、Workbench实时权威 | HWLAB runtime | | PJ2026-01060107 | NC01 CI资源治理 | [PJ2026-01060107 NC01 CI资源治理](PJ2026-01060107-nc01-ci-resource-governance.md) | NC01 构建并发、排队、资源合同、稳定去重和核心运行面保护 | 发布流水、YAML运维、CI/CD目标治理 | NC01 CI 与核心服务 | @@ -152,6 +152,21 @@ L2 与 L3 发布必须使用 `plan -> 人工审阅 -> trigger` 的受控手动 `plan` 必须是只读操作,并针对精确 target、lane 和 source commit 输出:env identity 及 reuse/build 判定、待构建镜像数量与服务列表、待 rollout 服务、全部受影响服务、范围基线和相对基线新增项。`plan` 不得同步 mirror、创建 snapshot、修改 branch、创建 PipelineRun 或写入运行面。 +- `plan identity` 必须由规范化的计划输入与执行范围确定: + - 输入至少绑定 target、consumer、lane、source commit、base commit、catalog authority、catalog source、catalog digest、registry probe 模式和结果; + - 执行范围至少绑定 build、rollout 和 affected 服务列表; + - 列表与对象键必须规范排序,时间戳、临时路径和运行编号不得参与 identity; + - 相同输入与范围必须产生相同 identity,任一绑定输入或范围变化必须产生不同 identity。 +- `trigger` 必须显式携带操作者刚审阅的 `plan identity`: + - 发送 webhook 前必须用当前 source、base、catalog 和 registry 输入重新生成计划; + - 重算 identity 与已审阅 identity 不一致时不得发送 webhook; + - webhook 只携带规范化计划事实,不引入可变计划存储或第二 authority。 +- Pipeline 必须在 build 前用同一组输入重新生成计划: + - 实际 identity 与 webhook 携带的已审阅 identity 不一致时立即失败; + - build、rollout 或 affected 范围任一扩大时必须输出 expected、actual 和新增服务; + - identity 校验通过前不得启动镜像构建,CLI 显示 `build=0` 时 Pipeline 不得构建镜像; + - 不得通过补跑、提高 timeout、忽略 warning 或继续后续 Task 绕过该失败。 + `plan` 恢复 artifact catalog 时必须满足: - 读取 owning YAML 声明的 GitOps read URL 与 branch; 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 3fb3a4be..2359def6 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -311,6 +311,10 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook", "release", "trigger", "--target", "NC01", "--consumer", "hwlab-nc01-v03", "--source-commit", "a".repeat(40), ])).rejects.toThrow("release trigger requires --confirm after reviewing release plan"); + await expect(runPlatformInfraPipelinesAsCodeCommand({} as never, [ + "release", "trigger", "--target", "NC01", "--consumer", "hwlab-nc01-v03", + "--source-commit", "a".repeat(40), "--confirm", + ])).rejects.toThrow("release trigger requires --plan-identity from the reviewed release plan"); const remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8"); const planStart = remote.indexOf("manual_release_plan_json()"); const actionStart = remote.indexOf("manual_release_action()"); @@ -329,6 +333,9 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook", expect(planAction).toContain("expandedServices"); expect(planAction).toContain("buildServices"); expect(planAction).toContain("rolloutServices"); + expect(planAction).toContain("--verify-reuse-registry"); + expect(planAction).toContain("--release-target"); + expect(planAction).toContain("planIdentity"); expect(planAction).toContain('gitops_read_url=$(cluster_service_url "$UNIDESK_PAC_MANUAL_GITOPS_READ_URL")'); expect(planAction).toContain('HWLAB_GIT_READ_URL="$gitops_read_url"'); expect(planAction).not.toContain('HWLAB_GIT_READ_URL="$UNIDESK_PAC_REPOSITORY_URL"'); @@ -337,8 +344,13 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook", .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).toContain("release-plan-identity-changed"); + expect(triggerAction).toContain("unidesk-release-plan-v1."); expect(triggerAction).not.toContain("kubectl create"); expect(triggerAction).not.toContain("PipelineRun"); + const sourceArtifactRenderer = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-source-artifact.ts"), "utf8"); + expect(sourceArtifactRenderer).toContain('if (name === "release-base-commit") return { name, value: "{{ body.before }}" }'); + expect(sourceArtifactRenderer).toContain('if (name === "reviewed-plan") return { name, value: "{{ body.head_commit.message }}" }'); }); test("PaC release scoped help stays on the manual plan-before-trigger path", async () => { @@ -356,22 +368,25 @@ test("PaC release scoped help stays on the manual plan-before-trigger path", asy expect(JSON.stringify(plan)).toContain("计划非空"); expect(JSON.stringify(trigger)).toContain("release trigger --target "); expect(JSON.stringify(trigger)).toContain("--confirm"); + expect(JSON.stringify(trigger)).toContain("--plan-identity"); 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; + const identity = `sha256:${"d".repeat(64)}`; + const args = ["plan", "NC01", "hwlab-nc01-v03", "a".repeat(40), "b".repeat(40), identity] as const; 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"); + expect(manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, remoteError: false })).toContain(`--plan-identity ${identity}`); 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, catalogBaseCommit: null, remoteError: false }); + const observed = manualReleaseNext("trigger", args[1], args[2], args[3], args[4], args[5], { 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"); @@ -388,6 +403,8 @@ test("PaC default release plan projection keeps review fields without changed-pa const source = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code.ts"), "utf8"); const projector = source.slice(source.indexOf("function compactManualReleasePlan"), source.indexOf("export function manualReleaseNext")); expect(projector).toContain("requestedScope"); + expect(projector).toContain("planIdentity"); + expect(projector).toContain("registryProbe"); expect(projector).toContain("envReuse"); expect(projector).toContain("buildServices"); expect(projector).toContain("rolloutServices"); diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index b8c97d8f..469d8fc2 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -289,7 +289,10 @@ NODE --artifact-catalog "$UNIDESK_PAC_MANUAL_ARTIFACT_CATALOG_PATH" \ --services "$UNIDESK_PAC_MANUAL_SERVICE_IDS" \ --registry-prefix "$UNIDESK_PAC_MANUAL_REGISTRY_PREFIX" \ - --base-image "$UNIDESK_PAC_MANUAL_BASE_IMAGE") >"$domain_plan_file" + --base-image "$UNIDESK_PAC_MANUAL_BASE_IMAGE" \ + --release-target "$UNIDESK_PAC_MANUAL_RELEASE_TARGET" \ + --release-consumer "$UNIDESK_PAC_MANUAL_RELEASE_CONSUMER" \ + --verify-reuse-registry) >"$domain_plan_file" else if [ -z "$base_commit" ]; then base_commit=$(git -C "$tmp/source" rev-parse "$source_commit^") @@ -302,6 +305,7 @@ NODE fi 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 crypto = require('node:crypto'); const changedPaths = fs.readFileSync(process.argv[2], 'utf8').split('\n').filter(Boolean); const domain = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); const configuredImages = JSON.parse(process.env.UNIDESK_PAC_MANUAL_IMAGE_REPOSITORIES_JSON || '[]'); @@ -325,10 +329,36 @@ const envReuse = domain ? { reusedServiceCount: Number(domain.serviceReusedCount || 0), buildSkippedServices: domain.imageBuildSkippedServices || [], } : { status: 'not-configured', groups: [], reusedServices: [], reusedServiceCount: 0, buildSkippedServices: [] }; +const stable = (value) => { + if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']'; + if (value && typeof value === 'object') return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stable(value[key])).join(',') + '}'; + return JSON.stringify(value); +}; +const identityScope = { + buildServices: [...buildServices].sort(), + rolloutServices: [...rolloutServices].sort(), + affectedServices: [...affectedServices].sort(), +}; +const genericIdentity = { + version: 'v1', + inputs: { + target: process.env.UNIDESK_PAC_MANUAL_RELEASE_TARGET, + consumer: process.env.UNIDESK_PAC_MANUAL_RELEASE_CONSUMER, + lane: process.env.UNIDESK_PAC_CONSUMER_LANE, + sourceCommitId: process.env.SOURCE_COMMIT, + baseCommitId: process.env.BASE_COMMIT, + catalog: domain?.artifactCatalog || null, + registryProbe: domain?.planIdentity?.inputs?.registryProbe || { enabled: false, services: [] }, + }, + scope: identityScope, +}; +const planIdentity = domain?.planIdentity?.value || `sha256:${crypto.createHash('sha256').update(stable(genericIdentity)).digest('hex')}`; const plan = { sourceCommit: process.env.SOURCE_COMMIT, baseCommit: process.env.BASE_COMMIT, sourceBranch: process.env.UNIDESK_PAC_MANUAL_SOURCE_BRANCH, + planIdentity, + registryProbe: domain?.planIdentity?.inputs?.registryProbe || genericIdentity.inputs.registryProbe, changedPathCount: changedPaths.length, changedPaths, artifactCatalog: domain?.artifactCatalog || null, @@ -379,6 +409,7 @@ manual_release_action() { const fs = require('node:fs'); const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); if (plan.scopeReview?.expanded === true) process.stdout.write('expanded'); +else if (!process.env.UNIDESK_PAC_MANUAL_PLAN_IDENTITY || process.env.UNIDESK_PAC_MANUAL_PLAN_IDENTITY !== plan.planIdentity) process.stdout.write('identity-changed'); 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 @@ -388,6 +419,11 @@ NODE printf '{"ok":false,"mutation":false,"error":"release-plan-scope-expanded","plan":%s,"webhook":null,"valuesPrinted":false}\n' "$plan" return 1 fi + if [ "$plan_gate" = "identity-changed" ]; then + rm -f "$plan_file" + printf '{"ok":false,"mutation":false,"error":"release-plan-identity-changed","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" @@ -396,15 +432,26 @@ NODE payload_file=$(mktemp) node - "$plan_file" >"$payload_file" <<'NODE' const fs = require('node:fs'); -const crypto = require('node:crypto'); const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); const byKind = { added: [], removed: [], modified: plan.changedPaths }; const owner = process.env.UNIDESK_PAC_GITEA_OWNER; const repo = process.env.UNIDESK_PAC_GITEA_REPO; const url = process.env.UNIDESK_PAC_REPOSITORY_URL.replace(/\/+$/u, ''); -const commit = { id: plan.sourceCommit, message: 'manual L2/L3 release', url: `${url}/commit/${plan.sourceCommit}`, added: byKind.added, removed: byKind.removed, modified: byKind.modified, author: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' }, committer: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' } }; +const reviewedPlan = { + version: 'v1', + planIdentity: plan.planIdentity, + target: process.env.UNIDESK_PAC_MANUAL_RELEASE_TARGET, + consumer: process.env.UNIDESK_PAC_MANUAL_RELEASE_CONSUMER, + sourceCommit: plan.sourceCommit, + baseCommit: plan.baseCommit, + buildServices: plan.buildServices, + rolloutServices: plan.rolloutServices, + affectedServices: plan.affectedServices, +}; +const reviewedPlanEnvelope = `unidesk-release-plan-v1.${Buffer.from(JSON.stringify(reviewedPlan)).toString('base64url')}`; +const commit = { id: plan.sourceCommit, message: reviewedPlanEnvelope, url: `${url}/commit/${plan.sourceCommit}`, added: byKind.added, removed: byKind.removed, modified: byKind.modified, author: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' }, committer: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' } }; const user = { id: 0, login: 'unidesk-cli', full_name: 'UniDesk CLI', email: '', avatar_url: '', username: 'unidesk-cli' }; -process.stdout.write(JSON.stringify({ ref: `refs/heads/${plan.sourceBranch}`, before: plan.baseCommit, after: plan.sourceCommit, compare_url: `${url}/compare/${plan.baseCommit}...${plan.sourceCommit}`, commits: [commit], head_commit: commit, repository: { id: 0, owner: { ...user, login: owner, username: owner }, name: repo, full_name: `${owner}/${repo}`, private: true, html_url: url, clone_url: `${url}.git`, default_branch: plan.sourceBranch }, pusher: user, sender: user })); +process.stdout.write(JSON.stringify({ ref: `refs/heads/${plan.sourceBranch}`, before: plan.baseCommit, after: plan.sourceCommit, compare_url: `${url}/compare/${plan.baseCommit}...${plan.sourceCommit}`, commits: [commit], total_commits: 1, head_commit: commit, repository: { id: 0, owner: { ...user, login: owner, username: owner }, name: repo, full_name: `${owner}/${repo}`, private: true, html_url: url, clone_url: `${url}.git`, default_branch: plan.sourceBranch }, pusher: user, sender: user })); NODE signature=$(node - "$payload_file" <<'NODE' const fs = require('node:fs'); diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index 51c6ec32..38dc324a 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -1075,6 +1075,12 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor const params = arrayRecords(desiredSpec.params, "Pipeline.spec.params"); return params.map((param) => { const name = requiredString(param.name, "Pipeline.spec.params[].name"); + if (binding.consumer.sourceArtifact.renderer === "hwlab-runtime-lane") { + if (name === "release-base-commit") return { name, value: "{{ body.before }}" }; + if (name === "release-target") return { name, value: binding.consumer.node }; + if (name === "release-consumer") return { name, value: binding.consumer.id }; + if (name === "reviewed-plan") return { name, value: "{{ body.head_commit.message }}" }; + } if (name === "revision" || name === "source-commit") return { name, value: "{{ revision }}" }; if (name === "source-stage-ref") { const prefix = Object.prototype.hasOwnProperty.call(binding.consumer.params, "source_snapshot_prefix") diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 4a6a045f..f070d726 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -315,6 +315,7 @@ interface ApplyOptions extends CommonOptions { interface ManualReleaseOptions extends CommonOptions { sourceCommit: string; baseCommit: string | null; + planIdentity: string | null; confirm: boolean; } @@ -364,11 +365,12 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf if (action === "release") { const releaseAction = args[1]; if (releaseAction === undefined || releaseAction === "--help" || releaseAction === "-h") return help("release"); - if (releaseAction !== "plan" && releaseAction !== "trigger") return { ok: false, action: "platform-infra-pipelines-as-code-release", mutation: false, error: "unsupported-release-action", usage: "pipelines-as-code release plan|trigger --target --consumer --source-commit [--base-commit ] [--confirm]", valuesPrinted: false }; + if (releaseAction !== "plan" && releaseAction !== "trigger") return { ok: false, action: "platform-infra-pipelines-as-code-release", mutation: false, error: "unsupported-release-action", usage: "pipelines-as-code release plan|trigger --target --consumer --source-commit [--base-commit ] [--plan-identity ] [--confirm]", valuesPrinted: false }; if (args.slice(2).includes("--help") || args.slice(2).includes("-h")) return help(`release-${releaseAction}`); const options = parseManualReleaseOptions(args.slice(2)); if (releaseAction === "plan" && options.confirm) throw new Error("release plan does not accept --confirm"); if (releaseAction === "trigger" && !options.confirm) throw new Error("release trigger requires --confirm after reviewing release plan"); + if (releaseAction === "trigger" && options.planIdentity === null) throw new Error("release trigger requires --plan-identity from the reviewed release plan"); return await manualRelease(config, releaseAction, options); } if (action === "apply") { @@ -573,13 +575,13 @@ function help(scope: string | null): Record { if (scope === "release" || scope === "release-plan" || scope === "release-trigger") { const action = scope === "release-plan" ? "plan" : scope === "release-trigger" ? "trigger" : null; const planUsage = "bun scripts/cli.ts platform-infra pipelines-as-code release plan --target --consumer --source-commit <40-sha> [--base-commit <40-sha>]"; - const triggerUsage = "bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target --consumer --source-commit <40-sha> [--base-commit <40-sha>] --confirm"; + const triggerUsage = "bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target --consumer --source-commit <40-sha> [--base-commit <40-sha>] --plan-identity --confirm"; return { 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 且 imageBuildCount、rolloutServiceCount、affectedServiceCount 至少一项非零时才可 trigger。三项均为零是终态 no-op,不发送 webhook。trigger 只发送 PaC webhook,由 PaC 创建 PipelineRun。", - review: ["rangeBaseline", "requestedScope", "effective", "envReuse", "imageBuildCount", "rolloutServiceCount", "affectedServiceCount", "scopeReview"], + review: ["planIdentity", "registryProbe", "rangeBaseline", "requestedScope", "effective", "envReuse", "imageBuildCount", "rolloutServiceCount", "affectedServiceCount", "scopeReview"], next: action === "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。", @@ -623,7 +625,7 @@ function help(scope: string | null): Record { "bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01", "bun scripts/cli.ts platform-infra pipelines-as-code automatic-webhooks disable --target [--confirm]", "bun scripts/cli.ts platform-infra pipelines-as-code release plan --target --consumer --source-commit [--base-commit ]", - "bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target --consumer --source-commit [--base-commit ] --confirm", + "bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target --consumer --source-commit [--base-commit ] --plan-identity --confirm", "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]", "bun scripts/cli.ts platform-infra pipelines-as-code diagnose-regression --target NC01 --consumer agentrun-nc01-v02 [--limit 20] [--json|--full]", @@ -1625,6 +1627,7 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger", const result = await capture(config, target.route, ["sh"], remoteScript(remoteAction, pac, target, repository, consumer, { ...options, dryRun: action === "plan", wait: false }, secrets, "", [consumer], false, [repository], { sourceCommit: options.sourceCommit, baseCommit: options.baseCommit, + planIdentity: options.planIdentity, sourceBranch: params.source_branch ?? "", imageRepositories, runtimeService: params.runtime_deployment ?? consumer.id, @@ -1635,6 +1638,8 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger", serviceIds: hwlabLane?.serviceIds ?? [], registryPrefix: hwlabLane?.registryPrefix ?? "", baseImage: hwlabLane?.baseImage ?? "", + releaseTarget: target.id, + releaseConsumer: consumer.id, }), { runtimeTimeoutMs: action === "plan" ? 120_000 : 150_000 }); const remote = parseJsonOutput(result.stdout) ?? {}; const fullReleasePlan = remote.plan && typeof remote.plan === "object" ? remote.plan as Record : null; @@ -1659,7 +1664,7 @@ 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, typeof fullReleasePlan?.baseCommit === "string" ? fullReleasePlan.baseCommit : options.baseCommit, { + next: manualReleaseNext(action, target.id, consumer.id, options.sourceCommit, typeof fullReleasePlan?.baseCommit === "string" ? fullReleasePlan.baseCommit : options.baseCommit, typeof fullReleasePlan?.planIdentity === "string" ? fullReleasePlan.planIdentity : options.planIdentity, { planSucceeded, scopeExpanded, scopeEmpty, @@ -1677,6 +1682,8 @@ function compactManualReleasePlan(plan: Record): Record ({ id: item.id, owner: item.owner, repo: item.repo }))), UNIDESK_PAC_MANUAL_SOURCE_COMMIT: manualRelease?.sourceCommit ?? "", UNIDESK_PAC_MANUAL_BASE_COMMIT: manualRelease?.baseCommit ?? "", + UNIDESK_PAC_MANUAL_PLAN_IDENTITY: manualRelease?.planIdentity ?? "", UNIDESK_PAC_MANUAL_SOURCE_BRANCH: manualRelease?.sourceBranch ?? "", UNIDESK_PAC_MANUAL_IMAGE_REPOSITORIES_JSON: JSON.stringify(manualRelease?.imageRepositories ?? []), UNIDESK_PAC_MANUAL_RUNTIME_SERVICE: manualRelease?.runtimeService ?? "", @@ -2479,6 +2489,8 @@ function remoteScript(action: "apply" | "status" | "history" | "diagnose-regress UNIDESK_PAC_MANUAL_SERVICE_IDS: (manualRelease?.serviceIds ?? []).join(","), UNIDESK_PAC_MANUAL_REGISTRY_PREFIX: manualRelease?.registryPrefix ?? "", UNIDESK_PAC_MANUAL_BASE_IMAGE: manualRelease?.baseImage ?? "", + UNIDESK_PAC_MANUAL_RELEASE_TARGET: manualRelease?.releaseTarget ?? "", + UNIDESK_PAC_MANUAL_RELEASE_CONSUMER: manualRelease?.releaseConsumer ?? "", UNIDESK_PAC_WEBHOOK_URL: webhookUrl, UNIDESK_PAC_WEBHOOK_SECRET: secrets.webhookSecret, UNIDESK_PAC_REPOSITORY_NAME: repository.name, @@ -3869,6 +3881,7 @@ function parseManualReleaseOptions(args: string[]): ManualReleaseOptions { let consumerId: string | null = null; let sourceCommit: string | null = null; let baseCommit: string | null = null; + let planIdentity: string | null = null; let confirm = false; let full = false; let raw = false; @@ -3879,6 +3892,7 @@ function parseManualReleaseOptions(args: string[]): ManualReleaseOptions { else if (arg === "--consumer") consumerId = optionValue(args, ++index, arg); else if (arg === "--source-commit") sourceCommit = optionValue(args, ++index, arg); else if (arg === "--base-commit") baseCommit = optionValue(args, ++index, arg); + else if (arg === "--plan-identity") planIdentity = optionValue(args, ++index, arg); else if (arg === "--confirm") confirm = true; else if (arg === "--full") full = true; else if (arg === "--raw") raw = true; @@ -3888,7 +3902,8 @@ function parseManualReleaseOptions(args: string[]): ManualReleaseOptions { if (consumerId === null) throw new Error("release requires --consumer "); if (sourceCommit === null || !/^[0-9a-f]{40}$/iu.test(sourceCommit)) throw new Error("release requires --source-commit <40-hex-sha>"); if (baseCommit !== null && !/^[0-9a-f]{40}$/iu.test(baseCommit)) throw new Error("--base-commit must be a 40-hex SHA"); - return { targetId, consumerId, sourceCommit: sourceCommit.toLowerCase(), baseCommit: baseCommit?.toLowerCase() ?? null, confirm, full, raw, json }; + if (planIdentity !== null && !/^sha256:[0-9a-f]{64}$/iu.test(planIdentity)) throw new Error("--plan-identity must be sha256:<64-hex>"); + return { targetId, consumerId, sourceCommit: sourceCommit.toLowerCase(), baseCommit: baseCommit?.toLowerCase() ?? null, planIdentity: planIdentity?.toLowerCase() ?? null, confirm, full, raw, json }; } function optionValue(args: readonly string[], index: number, option: string): string {