From 4e8c572e10d9a6b47095b3b5fa73e2605d18cf54 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 4 Jul 2026 07:06:32 +0000 Subject: [PATCH] fix: verify branch follower reuse plan consumption --- scripts/native/cicd/branch-follower-gate.mjs | 310 ++++++++++++++++++- scripts/src/cicd-gates.ts | 5 +- 2 files changed, 305 insertions(+), 10 deletions(-) diff --git a/scripts/native/cicd/branch-follower-gate.mjs b/scripts/native/cicd/branch-follower-gate.mjs index 7739bbf8..1d559839 100644 --- a/scripts/native/cicd/branch-follower-gate.mjs +++ b/scripts/native/cicd/branch-follower-gate.mjs @@ -39,7 +39,7 @@ const source = { const gitMirror = gitMirrorSummary(sourceCommit); let evidence; -if (gate === "reuse-plan") evidence = reusePlanEvidence(sourceCommit); +if (gate === "reuse-plan") evidence = await reusePlanEvidence(sourceCommit); else if (gate === "ci-taskrun-plan") evidence = await ciTaskRunEvidence(sourceCommit); else if (gate === "cd-rollout-plan") evidence = await cdRolloutEvidence(sourceCommit); else if (gate === "post-deploy-health") evidence = await postDeployHealthEvidence(sourceCommit); @@ -62,26 +62,45 @@ console.log(JSON.stringify({ bounded: true, })); -function reusePlanEvidence(commit) { +async function reusePlanEvidence(commit) { const reuse = readReuseConfig(commit); + const decisions = reuse.present === true ? reusePlanDecisions(reuse) : []; + const summary = reusePlanDecisionSummary(decisions); return { - ok: source.snapshotReady && gitMirror.ok === true && reuse.present === true && reuse.serviceCount > 0, - gitMirror, - reuse, + ok: source.snapshotReady && gitMirror.ok === true && reuse.present === true && reuse.serviceCount > 0 && decisions.length > 0, + gitMirror: compactGitMirrorEvidence(gitMirror), + reuse: compactReuseEvidence(reuse), + decisions: boundedDecisions(decisions), + decisionSummary: summary, + contract: { + consumer: "adapter-ci", + fields: "serviceId sourceIdentity envIdentity runtimeReuse envReuse skipImageBuild buildDecision reusableImageRef reason", + expectation: "CI consumes skipImageBuild from this plan; it does not re-infer build/skip from TaskRun logs or source file scans.", + }, }; } async function ciTaskRunEvidence(commit) { if (!commit || !tektonNamespace || !pipelineRunPrefix) return notConfigured("tekton"); const pipelineRunName = `${pipelineRunPrefix}-${commit.slice(0, 12)}`; + const reusePlan = await reusePlanEvidence(commit); const pipelineRun = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelineruns/${encodeURIComponent(pipelineRunName)}`, false); const prStatus = pipelineRunStatus(pipelineRun); const pipelineRef = str(pipelineRun?.spec?.pipelineRef?.name); const pipeline = pipelineRef ? await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelines/${encodeURIComponent(pipelineRef)}`, false) : null; const taskRuns = await getJson(`/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/taskruns?labelSelector=${encodeURIComponent(`tekton.dev/pipelineRun=${pipelineRunName}`)}`, false); const taskSummary = taskRunsSummary(taskRuns); + const planArtifacts = planArtifactsEvidence(pipelineRunName); + const buildTaskRunServices = buildTaskServices(taskRuns); + const ciConsumption = ciConsumptionSummary(reusePlan.decisions || [], planArtifacts, buildTaskRunServices); return { - ok: prStatus.succeeded === true && taskSummary.failedCount === 0 && taskSummary.activeCount === 0, + ok: prStatus.succeeded === true && taskSummary.failedCount === 0 && taskSummary.activeCount === 0 && ciConsumption.ok === true, + reusePlan: { + ok: reusePlan.ok, + decisionSummary: compactDecisionSummary(reusePlan.decisionSummary), + }, + planArtifacts, + ciConsumption, pipelineRun: prStatus, pipeline: { name: pipelineRef, @@ -89,7 +108,214 @@ async function ciTaskRunEvidence(commit) { tasks: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.slice(0, 12).map((task) => ({ name: str(task?.name), runAfter: Array.isArray(task?.runAfter) ? task.runAfter.slice(0, 6) : [] })) : [], tasksTruncated: Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks.length > 12 : false, }, - taskRuns: taskSummary, + taskRuns: compactCiTaskRuns(taskSummary), + }; +} + +function reusePlanDecisions(reuse) { + const baseRef = sourceStageRef ? parentRef(sourceStageRef) : null; + return (reuse.serviceSpecs || []).map((service) => { + const codePaths = service.runtimeReuse?.codeIdentityPaths || []; + const envPaths = uniqueStrings([...(service.runtimeReuse?.envIdentityPaths || []), ...(service.envReuse?.envIdentityFiles || [])]); + const sourceIdentity = identityComparison(sourceStageRef, baseRef, codePaths); + const envIdentity = identityComparison(sourceStageRef, baseRef, envPaths); + const runtimeEnabled = service.runtimeReuse?.enabled !== false; + const envEnabled = service.envReuse?.enabled !== false; + const runtimeHit = runtimeEnabled && sourceIdentity.hit === true && envIdentity.hit === true; + const envHit = envEnabled && envIdentity.hit === true; + const skipImageBuild = runtimeHit || envHit; + return { + serviceId: service.id, + sourceIdentity, + envIdentity, + runtimeReuse: { + enabled: runtimeEnabled, + hit: runtimeHit, + reason: runtimeHit ? "source-and-env-identity-hit" : missReason(sourceIdentity, envIdentity, runtimeEnabled), + }, + envReuse: { + enabled: envEnabled, + hit: envHit, + reason: envHit ? "env-identity-hit" : missReason(null, envIdentity, envEnabled), + }, + skipImageBuild, + buildDecision: skipImageBuild ? "skipImageBuild" : "buildImage", + reusableImageRef: null, + reusableImageRefKnown: false, + reason: decisionReason({ runtimeHit, envHit, runtimeEnabled, envEnabled, sourceIdentity, envIdentity }), + }; + }); +} + +function reusePlanDecisionSummary(decisions) { + const skip = decisions.filter((item) => item.skipImageBuild === true).map((item) => item.serviceId).sort(); + const build = decisions.filter((item) => item.skipImageBuild !== true).map((item) => item.serviceId).sort(); + return { + serviceCount: decisions.length, + skipImageBuildCount: skip.length, + buildImageCount: build.length, + skipImageBuildServices: skip, + buildImageServices: build, + }; +} + +function compactDecisionSummary(summary) { + return { + serviceCount: summary?.serviceCount ?? null, + skipImageBuildCount: summary?.skipImageBuildCount ?? null, + buildImageCount: summary?.buildImageCount ?? null, + }; +} + +function identityComparison(currentRef, baseRef, paths) { + const current = identityDigest(currentRef, paths); + const previous = baseRef ? identityDigest(baseRef, paths) : null; + const configured = paths.length > 0; + const hit = configured && previous !== null && current.sha256 !== null && previous.sha256 !== null && current.sha256 === previous.sha256; + return { + configured, + paths: paths.slice(0, 12), + pathsTruncated: paths.length > 12, + pathCount: paths.length, + current: current.sha256, + previous: previous?.sha256 ?? null, + currentMissingCount: current.missingCount, + previousMissingCount: previous?.missingCount ?? null, + hit, + status: !configured ? "not-configured" : previous === null ? "base-missing" : hit ? "hit" : "miss", + }; +} + +function identityDigest(ref, paths) { + if (!ref || paths.length === 0) return { sha256: null, missingCount: 0 }; + const hash = createHash("sha256"); + let missingCount = 0; + for (const path of paths) { + const entries = gitTreeEntries(ref, path); + if (entries.length === 0) missingCount += 1; + hash.update(path); + hash.update("\0"); + for (const entry of entries) { + hash.update(entry); + hash.update("\0"); + } + } + return { sha256: hash.digest("hex"), missingCount }; +} + +function gitTreeEntries(ref, path) { + try { + const out = execFileSync("git", [`--git-dir=${repoPath}`, "ls-tree", "-r", "-z", "--full-tree", ref, "--", path], { encoding: "utf8", maxBuffer: 1024 * 1024 }); + return out.split("\0").filter(Boolean).sort(); + } catch { + return []; + } +} + +function parentRef(ref) { + try { + const out = execFileSync("git", [`--git-dir=${repoPath}`, "rev-parse", "--verify", `${ref}^`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return sha(out) ? out : null; + } catch { + return null; + } +} + +function missReason(sourceIdentity, envIdentity, enabled) { + if (!enabled) return "reuse-disabled"; + if (sourceIdentity?.configured === false || envIdentity?.configured === false) return "identity-not-configured"; + if (sourceIdentity?.status === "base-missing" || envIdentity?.status === "base-missing") return "base-identity-missing"; + if (sourceIdentity?.status === "miss") return "source-identity-miss"; + if (envIdentity?.status === "miss") return "env-identity-miss"; + return "identity-miss"; +} + +function decisionReason(input) { + if (input.runtimeHit) return "runtime-reuse-hit"; + if (input.envHit) return "env-reuse-hit"; + if (input.envIdentity.configured === false) return "env-identity-not-configured"; + if (input.envIdentity.status === "base-missing") return "previous-env-identity-unavailable"; + if (input.envIdentity.status === "miss") return "env-identity-changed"; + if (!input.runtimeEnabled && !input.envEnabled) return "reuse-disabled"; + return "reuse-miss"; +} + +function planArtifactsEvidence(pipelineRunName) { + if (!tektonNamespace || !pipelineRunName) return { ok: false, degradedReason: "tekton-not-configured" }; + try { + const text = execFileSync("node", ["./plan-artifacts.mjs", tektonNamespace, pipelineRunName], { encoding: "utf8", maxBuffer: 512 * 1024 }); + return JSON.parse(text); + } catch (error) { + return { ok: false, degradedReason: "plan-artifacts-query-failed", reason: shortText(error?.message || String(error)) }; + } +} + +function ciConsumptionSummary(decisions, planArtifacts, buildTaskRunServices) { + const skipExpected = decisions.filter((item) => item.skipImageBuild === true).map((item) => item.serviceId).sort(); + const buildExpected = decisions.filter((item) => item.skipImageBuild !== true).map((item) => item.serviceId).sort(); + const buildObserved = uniqueStrings([...strings(planArtifacts?.buildServices), ...buildTaskRunServices]).sort(); + const reusedObserved = strings(planArtifacts?.reusedServices).sort(); + const skippedObserved = uniqueStrings(reusedObserved).sort(); + const unexpectedBuild = skipExpected.filter((serviceId) => buildObserved.includes(serviceId) && !reusedObserved.includes(serviceId)); + const missingBuild = buildExpected.filter((serviceId) => !buildObserved.includes(serviceId) && !reusedObserved.includes(serviceId)); + const ok = planArtifacts?.ok === true && unexpectedBuild.length === 0 && missingBuild.length === 0; + return { + ok, + reason: unexpectedBuild.length > 0 || missingBuild.length > 0 ? "ci-consumption-mismatch" : planArtifacts?.ok === true ? "ci-consumed-reuse-plan" : "plan-artifacts-event-missing", + source: "reuse-plan-decisions-compared-to-plan-artifacts-g14-ci-plan", + expected: { + skipImageBuildCount: skipExpected.length, + buildImageCount: buildExpected.length, + }, + observed: { + buildServicesCount: buildObserved.length, + buildTaskRunServices, + reusedServicesCount: reusedObserved.length, + skippedOrReusedServicesCount: skippedObserved.length, + buildSkippedCount: typeof planArtifacts?.buildSkippedCount === "number" ? planArtifacts.buildSkippedCount : null, + }, + mismatches: compactCiMismatches(unexpectedBuild, missingBuild), + }; +} + +function compactCiMismatches(unexpectedBuild, missingBuild) { + return [ + ...(unexpectedBuild.length > 0 ? [{ reason: "expected-skipImageBuild-but-ci-buildServices-includes-service", serviceIds: unexpectedBuild }] : []), + ...(missingBuild.length > 0 ? [{ reason: "expected-buildImage-but-ci-plan-has-no-build-or-reuse-service", serviceIds: missingBuild }] : []), + ]; +} + +function buildTaskServices(list) { + const items = Array.isArray(list?.items) ? list.items : []; + return uniqueStrings(items.flatMap((item) => { + const taskName = str(item?.metadata?.labels?.["tekton.dev/pipelineTask"]) || str(item?.spec?.taskRef?.name) || ""; + const match = /^build-(.+)$/u.exec(taskName); + return match ? [match[1]] : []; + })).sort(); +} + +function compactCiTaskRuns(summary) { + return { + count: summary.count, + slowThresholdSeconds: summary.slowThresholdSeconds, + failedCount: summary.failedCount, + activeCount: summary.activeCount, + slowCount: summary.slowCount, + failedItems: summary.failedItems.slice(0, 4).map(compactCiTaskItem), + activeItems: summary.activeItems.slice(0, 4).map(compactCiTaskItem), + slowItems: summary.slowItems.slice(0, 4).map(compactCiTaskItem), + timeline: summary.timeline.slice(0, 10), + timelineTruncated: summary.timeline.length > 10 || summary.timelineTruncated === true, + performance: summary.performance, + }; +} + +function compactCiTaskItem(item) { + return { + taskName: item.taskName, + status: item.status, + reason: item.reason, + durationSeconds: item.durationSeconds, }; } @@ -122,6 +348,7 @@ function readReuseConfig(commit) { const summary = summarizeRuntimeReuseConfig(parsed); return { ...summary, + serviceSpecs: parsed.services, bytes: Buffer.byteLength(text, "utf8"), sha256: summary.sha256 ?? createHash("sha256").update(text).digest("hex"), }; @@ -130,6 +357,63 @@ function readReuseConfig(commit) { } } +function compactReuseEvidence(reuse) { + return { + ok: reuse.ok === true, + present: reuse.present === true, + path: reuse.path || "gitops/reuse.ymal", + sha256: shortFingerprint(reuse.sha256), + serviceCount: reuse.serviceCount ?? null, + errors: Array.isArray(reuse.errors) ? reuse.errors.slice(0, 3) : [], + bytes: reuse.bytes ?? null, + valuesRedacted: true, + }; +} + +function compactGitMirrorEvidence(value) { + return { + ok: value.ok === true, + sourceSnapshotReady: value.sourceSnapshotReady === true, + githubInSync: value.githubInSync, + pendingFlush: value.pendingFlush, + }; +} + +function boundedDecisions(decisions) { + return decisions.slice(0, 16).map((item) => ({ + serviceId: item.serviceId, + sourceIdentity: compactIdentity(item.sourceIdentity), + envIdentity: compactIdentity(item.envIdentity), + runtimeReuse: compactReuseHit(item.runtimeReuse), + envReuse: compactReuseHit(item.envReuse), + skipImageBuild: item.skipImageBuild, + buildDecision: item.buildDecision, + reusableImageRef: item.reusableImageRef, + reusableImageRefKnown: item.reusableImageRefKnown, + reason: item.reason, + })); +} + +function compactIdentity(value) { + return { + configured: value.configured, + pathCount: value.pathCount, + hit: value.hit, + status: value.status, + missing: { + current: value.currentMissingCount, + previous: value.previousMissingCount, + }, + }; +} + +function compactReuseHit(value) { + return { + enabled: value.enabled, + hit: value.hit, + }; +} + function gitMirrorSummary(commit) { const localSource = rev(`refs/heads/${sourceBranch}`); const githubSource = rev(`refs/mirror-stage/heads/${sourceBranch}`); @@ -408,6 +692,14 @@ function str(value) { return typeof value === "string" && value.length > 0 ? value : null; } +function strings(value) { + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +} + +function uniqueStrings(value) { + return [...new Set(value.filter((item) => typeof item === "string" && item.length > 0))]; +} + function sha(value) { return typeof value === "string" && /^[0-9a-f]{40}$/iu.test(value); } @@ -416,6 +708,10 @@ function shortSha(value) { return sha(value) ? value.slice(0, 12) : null; } +function shortFingerprint(value) { + return typeof value === "string" && value.length >= 12 ? value.slice(0, 12) : null; +} + function shortText(value) { const text = String(value || "").replace(/\s+/gu, " ").trim(); return text.length <= 300 ? text : text.slice(0, 300); diff --git a/scripts/src/cicd-gates.ts b/scripts/src/cicd-gates.ts index 402c09b3..2679a347 100644 --- a/scripts/src/cicd-gates.ts +++ b/scripts/src/cicd-gates.ts @@ -43,11 +43,10 @@ export async function runBranchFollowerGate(registry: BranchFollowerRegistry, fo timedOut: command.timedOut, elapsedMs: Date.now() - startedAt, parseError: parsed === null ? "stdout-json-parse-failed" : null, - stdoutTail: ok ? "" : redactText(tailText(command.stdout, 1600)), + stdoutTail: parsed === null ? redactText(tailText(command.stdout, 1600)) : "", stderrTail: ok ? "" : redactText(tailText(command.stderr, 1200)), }, parsedDownstreamCliOutput: false, - next: { gate: `bun scripts/cli.ts cicd branch-follower gate --follower ${follower.id} --gate ${options.gate} --json` }, }; } @@ -63,7 +62,7 @@ function gateJobManifest(registry: BranchFollowerRegistry, follower: FollowerSpe "tmpdir=$(mktemp -d)", "cleanup() { rm -rf \"$tmpdir\"; }", "trap cleanup EXIT INT TERM", - nativeCicdScriptLoadShell(["branch-follower-gate.sh", "branch-follower-gate.mjs", "reuse-config-summary.mjs"]), + nativeCicdScriptLoadShell(["branch-follower-gate.sh", "branch-follower-gate.mjs", "reuse-config-summary.mjs", "plan-artifacts.mjs"]), "\"$tmpdir/branch-follower-gate.sh\"", ].join("\n"); return {