Merge pull request #2789 from pikasTech/fix/2744-plan-admission

统一手动发布 plan 与 webhook identity
This commit is contained in:
Lyon
2026-07-21 21:52:28 +08:00
committed by GitHub
5 changed files with 116 additions and 16 deletions
@@ -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
@@ -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 <NODE>");
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");
@@ -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');
@@ -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")
@@ -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 <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>] [--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 <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>] [--plan-identity <sha256:id>] [--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<string, unknown> {
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 <NODE> --consumer <consumer> --source-commit <40-sha> [--base-commit <40-sha>]";
const triggerUsage = "bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target <NODE> --consumer <consumer> --source-commit <40-sha> [--base-commit <40-sha>] --confirm";
const triggerUsage = "bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target <NODE> --consumer <consumer> --source-commit <40-sha> [--base-commit <40-sha>] --plan-identity <sha256:id> --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 planHWLAB 默认以 catalog source 为范围基线,确认 rangeBaseline.catalogAligned=true、scopeReview.status=exact 且计划非空后才执行 release trigger --confirm,三项范围计数均为零时无需 trigger。",
@@ -623,7 +625,7 @@ function help(scope: string | null): Record<string, unknown> {
"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 <NODE> [--confirm]",
"bun scripts/cli.ts platform-infra pipelines-as-code release plan --target <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>]",
"bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>] --confirm",
"bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>] --plan-identity <sha256:id> --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<string, any> : 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<string, any>): Record<string, unk
sourceCommit: plan.sourceCommit ?? null,
baseCommit: plan.baseCommit ?? null,
sourceBranch: plan.sourceBranch ?? null,
planIdentity: plan.planIdentity ?? null,
registryProbe: plan.registryProbe ?? null,
changedPathCount: plan.changedPathCount ?? null,
artifactCatalog: plan.artifactCatalog === null ? null : {
status: plan.artifactCatalog?.status ?? null,
@@ -1705,17 +1712,19 @@ export function manualReleaseNext(
consumerId: string,
sourceCommit: string,
baseCommit: string | null,
planIdentity: string | null,
outcome: { readonly planSucceeded: boolean; readonly scopeExpanded: boolean; readonly scopeEmpty: boolean; readonly catalogBaseCommit: string | null; readonly remoteError: boolean },
): string {
const nextBaseCommit = outcome.scopeExpanded && outcome.catalogBaseCommit !== null
? outcome.catalogBaseCommit
: baseCommit;
const baseSuffix = nextBaseCommit === null ? "" : " --base-commit " + nextBaseCommit;
const identitySuffix = planIdentity === null ? "" : " --plan-identity " + planIdentity;
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`
? `bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target ${targetId} --consumer ${consumerId} --source-commit ${sourceCommit}${baseSuffix}${identitySuffix} --confirm`
: `fix the source range, planner, or owning YAML, then rerun: ${plan}`;
}
return outcome.remoteError
@@ -2437,7 +2446,7 @@ export class PacReleaseManifestRenderError extends Error {
}
}
function remoteScript(action: "apply" | "status" | "history" | "diagnose-regression" | "debug-step" | "disable-automatic-webhooks" | "manual-release-plan" | "manual-release-trigger", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer], collectArtifactLogs = false, webhookRepositories: PacRepository[] = [repository], manualRelease?: { sourceCommit: string; baseCommit: string | null; sourceBranch: string; imageRepositories: string[]; runtimeService: string; planningNodeModules: string; artifactCatalogPath: string; gitopsReadUrl: string; gitopsBranch: string; serviceIds: readonly string[]; registryPrefix: string; baseImage: string }): string {
function remoteScript(action: "apply" | "status" | "history" | "diagnose-regression" | "debug-step" | "disable-automatic-webhooks" | "manual-release-plan" | "manual-release-trigger", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer], collectArtifactLogs = false, webhookRepositories: PacRepository[] = [repository], manualRelease?: { sourceCommit: string; baseCommit: string | null; planIdentity: string | null; sourceBranch: string; imageRepositories: string[]; runtimeService: string; planningNodeModules: string; artifactCatalogPath: string; gitopsReadUrl: string; gitopsBranch: string; serviceIds: readonly string[]; registryPrefix: string; baseImage: string; releaseTarget: string; releaseConsumer: string }): string {
const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`;
const admissionIdentity = pacAdmissionDesiredIdentity(target.id);
const rbacIdentity = pacConsumerRbacDesiredIdentity(target.id);
@@ -2468,6 +2477,7 @@ function remoteScript(action: "apply" | "status" | "history" | "diagnose-regress
UNIDESK_PAC_WEBHOOK_REPOSITORIES_JSON: JSON.stringify(webhookRepositories.map((item) => ({ 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 <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 {