Merge pull request #2745 from pikasTech/fix/2744-plan-identity
Pipelines as Code CI / hwlab-nc01-v03-ci-poll-a572e0c32211e5ce831082495e28877315043016 Failed

阻止 Pipeline 扩大已审阅发布范围
This commit is contained in:
Lyon
2026-07-21 21:53:03 +08:00
committed by GitHub
10 changed files with 336 additions and 10 deletions
+9 -1
View File
@@ -9,7 +9,7 @@ metadata:
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'v0.3' && node == 'NC01'"
pipelinesascode.tekton.dev/max-keep-runs: "8"
unidesk.ai/owning-config-ref: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01"
unidesk.ai/effective-config-sha256: sha256:b132b19c115bb457e6ed7fc02d281aac0f6758208afa37977e114adaf1673e36
unidesk.ai/effective-config-sha256: sha256:01b954f394174302b5ebce2de98a24d091be1c5922fbc188ef5af72ff1bb569c
unidesk.ai/source-artifact-renderer: hwlab-runtime-lane
unidesk.ai/source-artifact-mode: remote-pipeline-annotation
pipelinesascode.tekton.dev/pipeline: ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml
@@ -86,3 +86,11 @@ spec:
value: registry
- name: runtime-ready-timeout-ms
value: "60000"
- name: release-base-commit
value: "{{ body.before }}"
- name: release-target
value: NC01
- name: release-consumer
value: hwlab-nc01-v03
- name: reviewed-plan
value: "{{ body.head_commit.message }}"
File diff suppressed because one or more lines are too long
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import { attachCiPlanIdentity } from "./src/ci-plan-identity.mjs";
import { compareReviewedPlan, decodeReviewedPlan } from "./ci/verify-reviewed-plan.mjs";
const sourceCommit = "a".repeat(40);
const baseCommit = "b".repeat(40);
test("plan identity is stable across service and scope ordering", () => {
const left = identifiedPlan({
services: [registryService("worker"), registryService("api")],
buildServices: ["worker", "api"]
});
const right = identifiedPlan({
services: [registryService("api"), registryService("worker")],
buildServices: ["api", "worker"]
});
assert.equal(left.planIdentity.value, right.planIdentity.value);
});
test("registry probe result and build scope change plan identity", () => {
const reused = identifiedPlan({ services: [registryService("api", "present")] });
const missing = identifiedPlan({
services: [registryService("api", "missing")],
buildServices: ["api"],
rolloutServices: ["api"],
affectedServices: ["api"]
});
assert.notEqual(reused.planIdentity.value, missing.planIdentity.value);
});
test("reviewed plan admission rejects scope additions before build", () => {
const expectedPlan = identifiedPlan({ rolloutServices: ["api"], affectedServices: ["api"] });
const actualPlan = identifiedPlan({
buildServices: ["worker"],
rolloutServices: ["api", "worker"],
affectedServices: ["api", "worker"]
});
const result = compareReviewedPlan(envelope(expectedPlan), actualPlan);
assert.equal(result.ok, false);
assert.equal(result.scopeExpanded, true);
assert.deepEqual(result.additions.buildServices, ["worker"]);
assert.deepEqual(result.additions.rolloutServices, ["worker"]);
});
test("reviewed plan envelope round trips without a second authority", () => {
const plan = identifiedPlan({ rolloutServices: ["api"], affectedServices: ["api"] });
const expected = envelope(plan);
const encoded = `unidesk-release-plan-v1.${Buffer.from(JSON.stringify(expected)).toString("base64url")}`;
assert.deepEqual(decodeReviewedPlan(encoded), expected);
assert.equal(compareReviewedPlan(expected, plan).ok, true);
});
function identifiedPlan(overrides = {}) {
return attachCiPlanIdentity({
sourceCommitId: sourceCommit,
baseCommitId: baseCommit,
compatibility: { lane: "v03" },
inputs: { verifyReuseRegistry: true },
artifactCatalog: {
authority: "gitops-branch",
catalogSourceCommitId: baseCommit,
catalogSha256: "sha256:catalog",
gitopsCommitId: "c".repeat(40)
},
services: [],
buildServices: [],
rolloutServices: [],
affectedServices: [],
...overrides
}, { releaseTarget: "NC01", releaseConsumer: "hwlab-nc01-v03" });
}
function registryService(serviceId, status = "present") {
return { serviceId, reuseRegistry: { status, image: `registry/${serviceId}:env`, digest: `sha256:${serviceId}` } };
}
function envelope(plan) {
return {
version: "v1",
planIdentity: plan.planIdentity.value,
target: "NC01",
consumer: "hwlab-nc01-v03",
sourceCommit,
baseCommit,
buildServices: plan.buildServices,
rolloutServices: plan.rolloutServices,
affectedServices: plan.affectedServices
};
}
+9 -2
View File
@@ -2,6 +2,7 @@
import { execFileSync } from "node:child_process";
import { readArtifactCatalogSummary } from "./src/artifact-catalog-authority.mjs";
import { attachCiPlanIdentity } from "./src/ci-plan-identity.mjs";
const args = parseArgs(process.argv.slice(2));
@@ -32,16 +33,18 @@ if (noDepsPlan) {
noDepsPlan.artifactCatalog = artifactCatalog;
noDepsPlan.compatibility.artifactCatalog = artifactCatalog.catalogPath;
noDepsPlan.compatibility.artifactCatalogAuthority = artifactCatalog.authority;
process.stdout.write(args.pretty ? `${JSON.stringify(noDepsPlan, null, 2)}\n` : `${JSON.stringify(noDepsPlan)}\n`);
const identifiedPlan = attachCiPlanIdentity(noDepsPlan, args);
process.stdout.write(args.pretty ? `${JSON.stringify(identifiedPlan, null, 2)}\n` : `${JSON.stringify(identifiedPlan)}\n`);
process.exit(0);
}
}
const { createCiPlan } = await import("./src/ci-plan-lib.mjs");
const plan = await createCiPlan(args);
const plan = attachCiPlanIdentity(await createCiPlan(args), args);
process.stdout.write(args.pretty ? `${JSON.stringify(plan, null, 2)}\n` : `${JSON.stringify(plan)}\n`);
function tryCreateNoDepsPlan(options) {
if (options.verifyReuseRegistry === true) return null;
const services = Array.isArray(options.services) ? options.services.filter(Boolean) : [];
if (services.length === 0) return null;
const targetRef = options.targetRef ?? "HEAD";
@@ -51,10 +54,12 @@ function tryCreateNoDepsPlan(options) {
const summary = classifyNoDepsGlobalChange(changedPaths);
if (!summary.gitopsOnly && !summary.docsOnly && !summary.testOnly && summary.summary !== "no-source-diff") return null;
const sourceCommitId = gitValue(["rev-parse", targetRef]);
const baseCommitId = gitValue(["rev-parse", baseRef]);
const shortCommitId = gitValue(["rev-parse", "--short=7", targetRef]);
return {
planVersion: "v1",
sourceCommitId,
baseCommitId,
shortCommitId,
baseRef,
targetRef,
@@ -215,6 +220,8 @@ function parseArgs(argv) {
else if (arg === "--verify-reuse-registry") parsed.verifyReuseRegistry = true;
else if (arg === "--no-verify-reuse-registry") parsed.verifyReuseRegistry = false;
else if (arg === "--base-image") parsed.baseImage = readOption(argv, ++index, arg);
else if (arg === "--release-target") parsed.releaseTarget = readOption(argv, ++index, arg);
else if (arg === "--release-consumer") parsed.releaseConsumer = readOption(argv, ++index, arg);
else if (arg === "--services") parsed.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean);
else if (arg === "--pretty") parsed.pretty = true;
else if (arg === "--help" || arg === "-h") parsed.help = true;
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const args = parseArgs(process.argv.slice(2));
const actual = JSON.parse(await readFile(args.actualPlan, "utf8"));
const expected = decodeReviewedPlan(args.reviewedPlan);
const result = compareReviewedPlan(expected, actual);
process.stdout.write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exit(42);
}
export function decodeReviewedPlan(value) {
const prefix = "unidesk-release-plan-v1.";
if (!String(value).startsWith(prefix)) throw new Error("reviewed plan envelope is missing or unsupported");
const decoded = Buffer.from(String(value).slice(prefix.length), "base64url").toString("utf8");
const plan = JSON.parse(decoded);
if (plan?.version !== "v1") throw new Error("reviewed plan version is unsupported");
return plan;
}
export function compareReviewedPlan(expected, actual) {
const actualScope = scope(actual);
const expectedScope = scope(expected);
const expectedIdentity = text(expected?.planIdentity);
const actualIdentity = text(actual?.planIdentity?.value);
const expectedMetadata = {
target: text(expected?.target),
consumer: text(expected?.consumer),
sourceCommit: text(expected?.sourceCommit),
baseCommit: text(expected?.baseCommit)
};
const actualMetadata = {
target: text(actual?.planIdentity?.inputs?.target),
consumer: text(actual?.planIdentity?.inputs?.consumer),
sourceCommit: text(actual?.sourceCommitId),
baseCommit: text(actual?.baseCommitId)
};
const additions = {
buildServices: difference(actualScope.buildServices, expectedScope.buildServices),
rolloutServices: difference(actualScope.rolloutServices, expectedScope.rolloutServices),
affectedServices: difference(actualScope.affectedServices, expectedScope.affectedServices)
};
const identityMatches = Boolean(expectedIdentity) && expectedIdentity === actualIdentity;
const metadataMatches = Object.keys(expectedMetadata).every((key) => expectedMetadata[key] && expectedMetadata[key] === actualMetadata[key]);
const scopeExpanded = Object.values(additions).some((items) => items.length > 0);
return {
event: "reviewed-plan-admission",
ok: identityMatches && metadataMatches && !scopeExpanded,
identityMatches,
metadataMatches,
scopeExpanded,
expectedIdentity,
actualIdentity,
expectedMetadata,
actualMetadata,
expectedScope,
actualScope,
additions,
valuesPrinted: false
};
}
function scope(value) {
return {
buildServices: sortedStrings(value?.buildServices ?? value?.scope?.buildServices),
rolloutServices: sortedStrings(value?.rolloutServices ?? value?.scope?.rolloutServices),
affectedServices: sortedStrings(value?.affectedServices ?? value?.scope?.affectedServices)
};
}
function difference(actual, expected) {
const expectedSet = new Set(expected);
return actual.filter((item) => !expectedSet.has(item));
}
function sortedStrings(value) {
return [...new Set(Array.isArray(value) ? value.map(text).filter(Boolean) : [])].sort();
}
function text(value) {
return typeof value === "string" ? value.trim() : "";
}
function parseArgs(argv) {
const result = { actualPlan: "", reviewedPlan: "" };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--actual-plan") result.actualPlan = readOption(argv, ++index, arg);
else if (arg === "--reviewed-plan") result.reviewedPlan = readOption(argv, ++index, arg);
else throw new Error(`unknown argument ${arg}`);
}
if (!result.actualPlan) throw new Error("--actual-plan is required");
if (!result.reviewedPlan) throw new Error("--reviewed-plan is required");
return result;
}
function readOption(argv, index, name) {
const value = argv[index];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
+6
View File
@@ -240,10 +240,16 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
assert.match(planArtifactsScript, /rolloutWithoutImageBuildServices/u);
assert.match(planArtifactsScript, /serviceReusedCount/u);
assert.match(planArtifactsScript, /artifactCatalog: plan\.artifactCatalog/u);
assert.match(planArtifactsScript, /--base-ref "\$\(params\.release-base-commit\)"/u);
assert.match(planArtifactsScript, /verify-reviewed-plan\.mjs/u);
assert.ok(planArtifacts.taskSpec.params.some((param) => param.name === "git-read-url"));
assert.ok(planArtifacts.taskSpec.params.some((param) => param.name === "gitops-branch"));
assert.ok(planArtifacts.params.some((param) => param.name === "lane" && param.value === "$(params.lane)"));
assert.ok(pipelineJson.spec.params.some((param) => param.name === "runtime-ready-timeout-ms" && param.default === "60000"));
for (const name of ["release-base-commit", "release-target", "release-consumer", "reviewed-plan"]) {
assert.ok(pipelineJson.spec.params.some((param) => param.name === name));
assert.ok(planArtifacts.taskSpec.params.some((param) => param.name === name));
}
const gitopsPromote = taskByName(pipelineJson, "gitops-promote");
assert.ok(gitopsPromote.taskSpec.results.some((result) => result.name === "runtime-ready-required"));
+71
View File
@@ -0,0 +1,71 @@
import { createHash } from "node:crypto";
export const CI_PLAN_IDENTITY_VERSION = "v1";
export function attachCiPlanIdentity(plan, context = {}) {
const canonical = canonicalCiPlanIdentity(plan, context);
const digest = createHash("sha256").update(stableStringify(canonical)).digest("hex");
return {
...plan,
planIdentity: {
version: CI_PLAN_IDENTITY_VERSION,
value: `sha256:${digest}`,
inputs: canonical.inputs,
scope: canonical.scope
}
};
}
export function canonicalCiPlanIdentity(plan, context = {}) {
const services = Array.isArray(plan?.services) ? plan.services : [];
return {
version: CI_PLAN_IDENTITY_VERSION,
inputs: {
target: text(context.releaseTarget),
consumer: text(context.releaseConsumer),
lane: text(plan?.compatibility?.lane),
sourceCommitId: text(plan?.sourceCommitId),
baseCommitId: text(plan?.baseCommitId),
catalog: {
authority: text(plan?.artifactCatalog?.authority),
sourceCommitId: text(plan?.artifactCatalog?.catalogSourceCommitId),
digest: text(plan?.artifactCatalog?.catalogSha256),
gitopsCommitId: text(plan?.artifactCatalog?.gitopsCommitId)
},
registryProbe: {
enabled: plan?.inputs?.verifyReuseRegistry === true,
services: services.map((service) => ({
serviceId: text(service?.serviceId),
status: text(service?.reuseRegistry?.status),
image: text(service?.reuseRegistry?.image),
digest: text(service?.reuseRegistry?.digest)
})).filter((service) => service.serviceId).sort(compareServiceId)
}
},
scope: {
buildServices: sortedStrings(plan?.buildServices),
rolloutServices: sortedStrings(plan?.rolloutServices),
affectedServices: sortedStrings(plan?.affectedServices)
}
};
}
export function stableStringify(value) {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function sortedStrings(value) {
return [...new Set(Array.isArray(value) ? value.map(text).filter(Boolean) : [])].sort();
}
function compareServiceId(left, right) {
return left.serviceId.localeCompare(right.serviceId);
}
function text(value) {
return typeof value === "string" ? value.trim() : "";
}
+2
View File
@@ -109,6 +109,7 @@ export async function createCiPlan(options = {}) {
const runtimeReuseConfig = await readStructuredFileIfPresent(repoRoot, runtimeReuseConfigPath, null);
const runtimeReuseByService = runtimeReuseServices(runtimeReuseConfig);
const sourceCommitId = await gitValue(repoRoot, ["rev-parse", targetRef]);
const baseCommitId = await gitValue(repoRoot, ["rev-parse", baseRef]);
const shortCommitId = await gitValue(repoRoot, ["rev-parse", "--short=7", targetRef]);
const changedPaths = await changedPathsBetween(repoRoot, baseRef, targetRef);
const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean);
@@ -390,6 +391,7 @@ export async function createCiPlan(options = {}) {
return {
planVersion: CI_PLAN_VERSION,
sourceCommitId,
baseCommitId,
shortCommitId,
baseRef,
targetRef,
+15 -3
View File
@@ -313,7 +313,11 @@ function planArtifactsTask({ serviceIds = defaultServiceIds } = {}) {
{ name: "revision" },
{ name: "catalog-path" },
{ name: "registry-prefix" },
{ name: "services" }
{ name: "services" },
{ name: "release-base-commit" },
{ name: "release-target" },
{ name: "release-consumer" },
{ name: "reviewed-plan" }
],
results: serviceIds.flatMap((serviceId) => [
{ name: affectedResultName(serviceId), description: `${serviceId} rollout affected according to ci-plan` },
@@ -340,7 +344,11 @@ function planArtifactsTask({ serviceIds = defaultServiceIds } = {}) {
{ name: "revision", value: "$(params.revision)" },
{ name: "catalog-path", value: "$(params.catalog-path)" },
{ name: "registry-prefix", value: "$(params.registry-prefix)" },
{ name: "services", value: "$(params.services)" }
{ name: "services", value: "$(params.services)" },
{ name: "release-base-commit", value: "$(params.release-base-commit)" },
{ name: "release-target", value: "$(params.release-target)" },
{ name: "release-consumer", value: "$(params.release-consumer)" },
{ name: "reviewed-plan", value: "$(params.reviewed-plan)" }
]
};
}
@@ -580,7 +588,11 @@ function tektonPipeline(args = { lane: "node" }, deploy = null) {
{ name: "services", type: "string", default: servicesParamForLane(args.lane, deploy) },
{ name: "base-image", type: "string", default: defaultDevBaseImage },
{ name: "build-cache-mode", type: "string", default: "registry" },
{ name: "runtime-ready-timeout-ms", type: "string", default: "60000" }
{ name: "runtime-ready-timeout-ms", type: "string", default: "60000" },
{ name: "release-base-commit", type: "string", description: "Reviewed release plan base commit." },
{ name: "release-target", type: "string", description: "Reviewed release target." },
{ name: "release-consumer", type: "string", description: "Reviewed release consumer." },
{ name: "reviewed-plan", type: "string", description: "Reviewed release plan envelope supplied by the manual webhook." }
],
workspaces: [
{ name: "source" },
@@ -10,7 +10,8 @@ if [ -d "$ci_node_deps/yaml" ] && [ ! -e node_modules/yaml ]; then
fi
test "$(git rev-parse HEAD)" = "$(params.revision)"
HWLAB_CATALOG_PATH="$(params.catalog-path)" HWLAB_GIT_URL="$(params.git-url)" HWLAB_GIT_READ_URL="$(params.git-read-url)" HWLAB_GITOPS_BRANCH="$(params.gitops-branch)" HWLAB_SERVICES="$(params.services)" node scripts/ci/restore-artifact-catalog.mjs
node scripts/ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" --verify-reuse-registry > /workspace/source/ci-plan.json
node scripts/ci-plan.mjs --lane "$(params.lane)" --base-ref "$(params.release-base-commit)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" --release-target "$(params.release-target)" --release-consumer "$(params.release-consumer)" --verify-reuse-registry > /workspace/source/ci-plan.json
node scripts/ci/verify-reviewed-plan.mjs --actual-plan /workspace/source/ci-plan.json --reviewed-plan "$(params.reviewed-plan)"
node - <<'NODE'
const fs = require("node:fs");
const plan = JSON.parse(fs.readFileSync("/workspace/source/ci-plan.json", "utf8"));
@@ -43,6 +44,7 @@ for (const entry of entries) {
}
fs.writeFileSync("/workspace/source/affected-services.json", JSON.stringify({
sourceCommitId: plan.sourceCommitId,
planIdentity: plan.planIdentity || null,
affectedServices: plan.affectedServices || [],
rolloutServices: plan.rolloutServices || plan.affectedServices || [],
buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId),
@@ -58,5 +60,5 @@ fs.writeFileSync("/workspace/source/affected-services.json", JSON.stringify({
services: plan.services || [],
entries
}, null, 2) + String.fromCharCode(10));
console.log(JSON.stringify({ event: "g14-ci-plan", sourceCommitId: plan.sourceCommitId, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], serviceReusedCount: plan.serviceReusedCount || 0, imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, artifactCatalog: plan.artifactCatalog || null, noImageBuildReason: plan.ciCdPlan?.noImageBuildReason || null, envArtifactGroups: plan.envArtifactGroups || [] }));
console.log(JSON.stringify({ event: "g14-ci-plan", sourceCommitId: plan.sourceCommitId, planIdentity: plan.planIdentity || null, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], serviceReusedCount: plan.serviceReusedCount || 0, imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, artifactCatalog: plan.artifactCatalog || null, noImageBuildReason: plan.ciCdPlan?.noImageBuildReason || null, envArtifactGroups: plan.envArtifactGroups || [] }));
NODE