Merge pull request #2775 from pikasTech/fix/hwlab-catalog-plan-baseline
fix(cicd): 以 catalog 对齐 HWLAB 发布基线
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 或扩大资源预算继续触发。
|
||||
|
||||
|
||||
@@ -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 <NODE>");
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -579,10 +579,10 @@ function help(scope: string | null): Record<string, unknown> {
|
||||
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<string, any>): Record<string, unk
|
||||
catalogSourceCommitId: plan.artifactCatalog?.catalogSourceCommitId ?? null,
|
||||
coverage: plan.artifactCatalog?.coverage ?? null,
|
||||
},
|
||||
rangeBaseline: plan.rangeBaseline ?? null,
|
||||
requestedScope: plan.requestedScope ?? null,
|
||||
envReuse: plan.envReuse ?? null,
|
||||
imageBuildCount: plan.imageBuildCount ?? null,
|
||||
@@ -1701,9 +1705,12 @@ export function manualReleaseNext(
|
||||
consumerId: string,
|
||||
sourceCommit: string,
|
||||
baseCommit: string | null,
|
||||
outcome: { readonly planSucceeded: boolean; readonly scopeExpanded: boolean; readonly scopeEmpty: boolean; readonly remoteError: boolean },
|
||||
outcome: { readonly planSucceeded: boolean; readonly scopeExpanded: boolean; readonly scopeEmpty: boolean; readonly catalogBaseCommit: string | null; readonly remoteError: boolean },
|
||||
): string {
|
||||
const baseSuffix = baseCommit === null ? "" : ` --base-commit ${baseCommit}`;
|
||||
const nextBaseCommit = outcome.scopeExpanded && outcome.catalogBaseCommit !== null
|
||||
? outcome.catalogBaseCommit
|
||||
: baseCommit;
|
||||
const baseSuffix = nextBaseCommit === null ? "" : " --base-commit " + nextBaseCommit;
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user