diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 62e8fe47..0a49e585 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -75,6 +75,7 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 禁止裸创建 PipelineRun、由 CLI 直建 PipelineRun,或恢复 Gitea 自动 hook; - L3 继续要求对应 L2 已通过和用户对当次生产发布的明确授权。 - 每次修改发布入口后,必须复核 `pipelines-as-code --help`、`release --help`、`release plan --help`、`release trigger --help` 与一条动态 `next`:帮助必须完整展示 plan/trigger 的参数、范围审阅字段和 `--confirm` 边界;动态 `next` 只能在范围准确时指向 trigger,范围扩大或触发失败时必须指向修复后重新 plan,不得遗漏或引导自动交付。 + - release plan 的 changed paths、domain plan 和 webhook payload 等可增长结构化数据必须通过临时文件或 stdin 传递,禁止放入环境变量或命令行参数;默认/`--json` 输出必须有界保留 env reuse、build、rollout、affected 与 scopeReview,完整 changed-path 明细只在显式 `--full|--raw` 披露。 - 节点级只读状态: - 优先用 `cicd status --node `; 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 f2d42385..349c3975 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; import { renderMirrorBootstrap } from "./platform-infra-gitea-render"; import { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; -import { PacReleaseManifestRenderError, parsePacConfigDocument, readPacConfig, renderPacReleaseManifest, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code"; +import { manualReleaseNext, PacReleaseManifestRenderError, parsePacConfigDocument, readPacConfig, renderPacReleaseManifest, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code"; import { materializeYamlComposition } from "./yaml-composition"; const mirror: GiteaMirrorRepository = { @@ -318,6 +318,32 @@ test("PaC release scoped help stays on the manual plan-before-trigger path", asy expect(JSON.stringify(trigger)).toContain("先执行 release plan"); }); +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 remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8"); + const action = remote.slice(remote.indexOf("manual_release_plan_json()"), remote.indexOf("repository_manifest()")); + expect(action).not.toContain('DOMAIN_PLAN="$domain_plan"'); + expect(action).not.toContain('PLAN_JSON="$plan"'); + expect(action).not.toContain('PAYLOAD="$payload"'); + expect(action).toContain('node - "$changed_paths_file" "$domain_plan_file"'); + expect(action).toContain('--data-binary "@$payload_file"'); +}); + +test("PaC default release plan projection keeps review fields without changed-path payload", () => { + 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("envReuse"); + expect(projector).toContain("buildServices"); + expect(projector).toContain("rolloutServices"); + expect(projector).toContain("affectedServices"); + expect(projector).toContain("scopeReview"); + expect(projector).not.toContain("changedPaths:"); +}); + test("PaC release manifest renders YAML-owned watcher startup backlog probes", () => { const pac = readPacConfig({ consumerId: "hwlab-nc01-v03", selectDefault: true }); const source = readFileSync(resolve(import.meta.dir, "fixtures/pac/release-leading-comment-only.yaml"), "utf8"); diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 96064f71..4d688c0b 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -233,7 +233,9 @@ manual_release_plan_json() { 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=$(git -C "$tmp/source" diff --name-only "$base_commit" "$source_commit") + changed_paths_file="$tmp/changed-paths.txt" + git -C "$tmp/source" diff --name-only "$base_commit" "$source_commit" >"$changed_paths_file" + domain_plan_file="$tmp/domain-plan.json" if [ "$UNIDESK_PAC_MANUAL_RENDERER" = "hwlab-runtime-lane" ]; then if [ ! -d "$UNIDESK_PAC_MANUAL_NODE_MODULES" ]; then printf '%s\n' "HWLAB planner node_modules is missing at the YAML-selected workspace" >&2 @@ -248,20 +250,21 @@ manual_release_plan_json() { HWLAB_SERVICES="$UNIDESK_PAC_MANUAL_SERVICE_IDS" \ node scripts/ci/restore-artifact-catalog.mjs ) - domain_plan=$(cd "$tmp/source" && node scripts/ci-plan.mjs \ + (cd "$tmp/source" && node scripts/ci-plan.mjs \ --lane "$UNIDESK_PAC_CONSUMER_LANE" \ --base-ref "$base_commit" \ --target-ref "$source_commit" \ --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") + --base-image "$UNIDESK_PAC_MANUAL_BASE_IMAGE") >"$domain_plan_file" else - domain_plan="" + printf 'null' >"$domain_plan_file" fi - SOURCE_COMMIT="$source_commit" BASE_COMMIT="$base_commit" CHANGED_PATHS="$changed_paths" DOMAIN_PLAN="$domain_plan" node <<'NODE' -const changedPaths = (process.env.CHANGED_PATHS || '').split('\n').filter(Boolean); -const domain = process.env.DOMAIN_PLAN ? JSON.parse(process.env.DOMAIN_PLAN) : null; + SOURCE_COMMIT="$source_commit" BASE_COMMIT="$base_commit" 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')); const configuredImages = JSON.parse(process.env.UNIDESK_PAC_MANUAL_IMAGE_REPOSITORIES_JSON || '[]'); const affectedServices = domain?.affectedServices || (changedPaths.length > 0 ? [process.env.UNIDESK_PAC_MANUAL_RUNTIME_SERVICE].filter(Boolean) : []); const rolloutServices = domain?.rolloutServices || affectedServices; @@ -320,14 +323,24 @@ manual_release_action() { printf '{"ok":true,"mutation":false,"plan":%s,"valuesPrinted":false}\n' "$plan" return fi - scope_expanded=$(PLAN_JSON="$plan" node -e 'const plan=JSON.parse(process.env.PLAN_JSON); process.stdout.write(plan.scopeReview?.expanded === true ? "1" : "0");') + plan_file=$(mktemp) + printf '%s' "$plan" >"$plan_file" + scope_expanded=$(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'); +NODE +) if [ "$scope_expanded" = "1" ]; 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 - payload=$(PLAN_JSON="$plan" node <<'NODE' + payload_file=$(mktemp) + node - "$plan_file" >"$payload_file" <<'NODE' +const fs = require('node:fs'); const crypto = require('node:crypto'); -const plan = JSON.parse(process.env.PLAN_JSON); +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; @@ -335,9 +348,14 @@ 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 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 })); +NODE + signature=$(node - "$payload_file" <<'NODE' +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const payload = fs.readFileSync(process.argv[2]); +process.stdout.write(crypto.createHmac('sha256', process.env.UNIDESK_PAC_WEBHOOK_SECRET).update(payload).digest('hex')); NODE ) - signature=$(PAYLOAD="$payload" node -e 'const crypto=require("node:crypto"); process.stdout.write(crypto.createHmac("sha256", process.env.UNIDESK_PAC_WEBHOOK_SECRET).update(process.env.PAYLOAD).digest("hex"));') service_ip=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get service "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.spec.clusterIP}') response_tmp=$(mktemp) delivery="unidesk-manual-$(date +%s)-$$" @@ -346,9 +364,10 @@ NODE -H 'X-Gitea-Event-Type: push' \ -H "X-Gitea-Delivery: $delivery" \ -H "X-Gitea-Signature: $signature" \ - --data-binary "$payload") + --data-binary "@$payload_file") case "$http_code" in 2*) ok=true ;; *) ok=false ;; esac rm -f "$response_tmp" + rm -f "$plan_file" "$payload_file" printf '{"ok":%s,"mutation":true,"plan":%s,"webhook":{"event":"push","delivery":"%s","httpStatus":%s,"sourceCommit":"%s","valuesPrinted":false},"valuesPrinted":false}\n' "$ok" "$plan" "$(json_string "$delivery")" "$http_code" "$(json_string "$UNIDESK_PAC_MANUAL_SOURCE_COMMIT")" [ "$ok" = true ] } diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 3bb5f7a5..43df3ba4 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -1638,23 +1638,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 releasePlan = action === "trigger" && typeof remote.error === "string" && fullReleasePlan !== null - ? { - sourceCommit: fullReleasePlan.sourceCommit ?? null, - baseCommit: fullReleasePlan.baseCommit ?? null, - artifactCatalog: fullReleasePlan.artifactCatalog === null ? null : { - status: fullReleasePlan.artifactCatalog?.status ?? null, - authority: fullReleasePlan.artifactCatalog?.authority ?? null, - catalogSourceCommitId: fullReleasePlan.artifactCatalog?.catalogSourceCommitId ?? null, - coverage: fullReleasePlan.artifactCatalog?.coverage ?? null, - }, - requestedScope: fullReleasePlan.requestedScope ?? null, - imageBuildCount: fullReleasePlan.imageBuildCount ?? null, - rolloutServiceCount: fullReleasePlan.rolloutServiceCount ?? null, - affectedServiceCount: fullReleasePlan.affectedServiceCount ?? null, - scopeReview: fullReleasePlan.scopeReview ?? null, - } - : fullReleasePlan; + const planSucceeded = result.exitCode === 0 && remote.ok === true && fullReleasePlan !== null; + const releasePlan = fullReleasePlan === null || options.full || options.raw + ? fullReleasePlan + : compactManualReleasePlan(fullReleasePlan); return { ok: result.exitCode === 0 && remote.ok === true, action: `platform-infra-pipelines-as-code-release-${action}`, @@ -1667,17 +1654,61 @@ 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: action === "plan" - ? scopeExpanded - ? `fix the source range, planner, or owning YAML, then rerun: bun scripts/cli.ts platform-infra pipelines-as-code release plan --target ${target.id} --consumer ${consumer.id} --source-commit ${options.sourceCommit}${options.baseCommit === null ? "" : ` --base-commit ${options.baseCommit}`}` - : `bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target ${target.id} --consumer ${consumer.id} --source-commit ${options.sourceCommit}${options.baseCommit === null ? "" : ` --base-commit ${options.baseCommit}`} --confirm` - : typeof remote.error === "string" - ? `fix the source range, planner, or owning YAML, then rerun: bun scripts/cli.ts platform-infra pipelines-as-code release plan --target ${target.id} --consumer ${consumer.id} --source-commit ${options.sourceCommit}${options.baseCommit === null ? "" : ` --base-commit ${options.baseCommit}`}` - : `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${target.id} --consumer ${consumer.id} --source-commit ${options.sourceCommit} --wait`, + next: manualReleaseNext(action, target.id, consumer.id, options.sourceCommit, options.baseCommit, { + planSucceeded, + scopeExpanded, + remoteError: typeof remote.error === "string", + }), valuesPrinted: false, }; } +function compactManualReleasePlan(plan: Record): Record { + return { + sourceCommit: plan.sourceCommit ?? null, + baseCommit: plan.baseCommit ?? null, + sourceBranch: plan.sourceBranch ?? null, + changedPathCount: plan.changedPathCount ?? null, + artifactCatalog: plan.artifactCatalog === null ? null : { + status: plan.artifactCatalog?.status ?? null, + authority: plan.artifactCatalog?.authority ?? null, + gitopsCommitId: plan.artifactCatalog?.gitopsCommitId ?? null, + catalogSourceCommitId: plan.artifactCatalog?.catalogSourceCommitId ?? null, + coverage: plan.artifactCatalog?.coverage ?? null, + }, + requestedScope: plan.requestedScope ?? null, + envReuse: plan.envReuse ?? null, + imageBuildCount: plan.imageBuildCount ?? null, + buildServices: plan.buildServices ?? [], + rolloutServiceCount: plan.rolloutServiceCount ?? null, + rolloutServices: plan.rolloutServices ?? [], + affectedServiceCount: plan.affectedServiceCount ?? null, + affectedServices: plan.affectedServices ?? [], + scopeReview: plan.scopeReview ?? null, + valuesPrinted: false, + }; +} + +export function manualReleaseNext( + action: "plan" | "trigger", + targetId: string, + consumerId: string, + sourceCommit: string, + baseCommit: string | null, + outcome: { readonly planSucceeded: boolean; readonly scopeExpanded: 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 (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` + : `fix the source range, planner, or owning YAML, then rerun: ${plan}`; + } + return outcome.remoteError + ? `fix the source range, planner, or owning YAML, then rerun: ${plan}` + : `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${targetId} --consumer ${consumerId} --source-commit ${sourceCommit} --wait`; +} + async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise> { const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true }); const target = resolveTarget(pac, options.targetId);