From 3886bbbb2c607729f3ad51f385ba8509bd951d03 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 4 Jul 2026 02:40:43 +0000 Subject: [PATCH 1/2] feat(cicd): add bounded pipeline evidence --- scripts/native/cicd/compact-native-object.mjs | 47 +++++++++++++- scripts/native/cicd/native-job.mjs | 25 ++++++++ scripts/native/cicd/read-native-bundle.sh | 4 ++ scripts/native/cicd/read-state-summary.mjs | 30 +++++++++ scripts/src/cicd-branch-follower.ts | 10 +++ scripts/src/cicd-debug.ts | 29 +++++++++ scripts/src/cicd-drilldown-render.ts | 20 ++++++ scripts/src/cicd-evidence.ts | 63 +++++++++++++++++++ scripts/src/cicd-native-bundle.ts | 1 + scripts/src/cicd-native-summary.ts | 1 + scripts/src/cicd-native.ts | 5 ++ scripts/src/cicd-render.ts | 28 +++++++++ scripts/src/cicd-types.ts | 2 + 13 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 scripts/src/cicd-evidence.ts diff --git a/scripts/native/cicd/compact-native-object.mjs b/scripts/native/cicd/compact-native-object.mjs index b021c86f..16cfe809 100644 --- a/scripts/native/cicd/compact-native-object.mjs +++ b/scripts/native/cicd/compact-native-object.mjs @@ -55,7 +55,10 @@ if (key === "pipelineRun") { apiVersion: input.apiVersion, kind: input.kind, metadata: metadata(input), - spec: { params: Array.isArray(input?.spec?.params) ? input.spec.params : [] }, + spec: { + pipelineRef: { name: input?.spec?.pipelineRef?.name || null }, + params: Array.isArray(input?.spec?.params) ? input.spec.params : [], + }, status: { conditions: Array.isArray(input?.status?.conditions) ? input.status.conditions : [], startTime: input?.status?.startTime || null, @@ -65,6 +68,48 @@ if (key === "pipelineRun") { reason: succeeded?.reason || null, }, }; +} else if (key === "pipeline") { + const tasks = Array.isArray(input?.spec?.tasks) ? input.spec.tasks : []; + const runtimeReady = tasks.find((item) => item?.name === "runtime-ready") || null; + const gitopsPromote = tasks.find((item) => item?.name === "gitops-promote") || null; + const gitopsResults = Array.isArray(gitopsPromote?.taskSpec?.results) ? gitopsPromote.taskSpec.results : []; + output = { + apiVersion: input.apiVersion, + kind: input.kind, + metadata: { + name: input?.metadata?.name || null, + namespace: input?.metadata?.namespace || null, + annotations: { + sourceConfig: input?.metadata?.annotations?.["hwlab.pikastech.local/source-config"] || null, + ciContract: input?.metadata?.annotations?.["hwlab.pikastech.local/ci-contract"] || null, + policy: input?.metadata?.annotations?.["hwlab.pikastech.local/policy"] || null, + }, + }, + spec: { + taskCount: tasks.length, + runtimeReadyTask: { + present: runtimeReady !== null, + name: runtimeReady?.name || null, + runAfter: Array.isArray(runtimeReady?.runAfter) ? runtimeReady.runAfter.slice(0, 6) : [], + when: Array.isArray(runtimeReady?.when) + ? runtimeReady.when.slice(0, 4).map((item) => ({ + input: item?.input || null, + operator: item?.operator || null, + values: Array.isArray(item?.values) ? item.values.slice(0, 6) : [], + })) + : [], + }, + gitopsPromoteTask: { + present: gitopsPromote !== null, + name: gitopsPromote?.name || null, + resultNames: gitopsResults + .map((item) => item?.name || null) + .filter((item) => typeof item === "string") + .slice(0, 8), + runtimeReadyRequiredResult: gitopsResults.some((item) => item?.name === "runtime-ready-required"), + }, + }, + }; } else if (key === "taskRuns") { const items = (Array.isArray(input?.items) ? input.items : []).map((item) => { const succeeded = condition(item, "Succeeded"); diff --git a/scripts/native/cicd/native-job.mjs b/scripts/native/cicd/native-job.mjs index ca85f4f3..04484eff 100644 --- a/scripts/native/cicd/native-job.mjs +++ b/scripts/native/cicd/native-job.mjs @@ -121,6 +121,7 @@ while (Date.now() <= deadline) { const complete = condition(latest, "Complete"); const failed = condition(latest, "Failed"); const logs = await logsTail(); +const summary = parseLastJsonSummary(logs); const timedOut = !complete && !failed; const output = { ok: Boolean(complete) && !timedOut, @@ -137,6 +138,7 @@ const output = { conditionReason: complete?.reason || failed?.reason || null, conditionMessage: complete?.message || failed?.message || null, logsTail: logs || null, + summary, statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, valuesRedacted: true, @@ -155,3 +157,26 @@ function requiredPositiveNumber(name) { if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`); return value; } + +function parseLastJsonSummary(text) { + const lines = String(text || "").split(/\r?\n/u).map((item) => item.trim()).filter(Boolean); + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + const parsed = JSON.parse(lines[index]); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return compactValue(parsed, 0); + } catch { + continue; + } + } + return null; +} + +function compactValue(value, depth) { + if (typeof value === "string") return value.length <= 240 ? value : `${value.slice(0, 160)} ... ${value.slice(-60)}`; + if (typeof value !== "object" || value === null) return value; + if (Array.isArray(value)) return value.slice(0, 8).map((item) => compactValue(item, depth + 1)); + if (depth >= 3) return "[bounded-object]"; + const output = {}; + for (const [key, child] of Object.entries(value).slice(0, 16)) output[key] = compactValue(child, depth + 1); + return output; +} diff --git a/scripts/native/cicd/read-native-bundle.sh b/scripts/native/cicd/read-native-bundle.sh index 845f2070..81b06cc7 100644 --- a/scripts/native/cicd/read-native-bundle.sh +++ b/scripts/native/cicd/read-native-bundle.sh @@ -95,6 +95,10 @@ if [ -n "${source_commit}" ] && [ -n "${tekton_namespace}" ] && [ -n "${pipeline sha12=$(printf '%s' "${source_commit}" | cut -c1-12) pipeline_run="${pipeline_run_prefix}-${sha12}" emit_kube_json pipelineRun "/apis/tekton.dev/v1/namespaces/${tekton_namespace}/pipelineruns/${pipeline_run}" + pipeline_ref="$(node -e "const fs=require('node:fs'); try { const value=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(value?.spec?.pipelineRef?.name || ''); } catch {}" "${tmpdir}/pipelineRun.raw" 2>/dev/null || true)" + if [ -n "${pipeline_ref}" ]; then + emit_kube_json pipeline "/apis/tekton.dev/v1/namespaces/${tekton_namespace}/pipelines/${pipeline_ref}" + fi emit_kube_json taskRuns "/apis/tekton.dev/v1/namespaces/${tekton_namespace}/taskruns?labelSelector=tekton.dev%2FpipelineRun%3D${pipeline_run}" emit_plan_artifacts "${tekton_namespace}" "${pipeline_run}" fi diff --git a/scripts/native/cicd/read-state-summary.mjs b/scripts/native/cicd/read-state-summary.mjs index 740490ae..3cf43bc1 100644 --- a/scripts/native/cicd/read-state-summary.mjs +++ b/scripts/native/cicd/read-state-summary.mjs @@ -145,10 +145,12 @@ function compactNativePayload(payload) { gitMirror: compactGitMirror(value.gitMirror), reuseConfig: compactReuseConfig(value.reuseConfig), tekton: compactTekton(value.tekton), + pipeline: compactPipeline(value.pipeline), taskRuns: compactTaskRuns(value.taskRuns), planArtifacts: compactPlanArtifacts(value.planArtifacts), argo: compactArgo(value.argo), runtime: compactRuntime(value.runtime), + refreshEvidence: compactRefreshEvidence(recordOrNull(recordOrNull(value.nativeCapabilities)?.controlPlaneRefresh)), errors: arrayStrings(value.errors).slice(0, 5), statusAuthority: stringOrNull(value.statusAuthority), parsedDownstreamCliOutput: false, @@ -200,6 +202,7 @@ function compactTekton(tekton) { if (value === null) return null; return { name: stringOrNull(value.name), + pipelineRefName: stringOrNull(value.pipelineRefName), succeeded: value.succeeded === true ? true : value.succeeded === false ? false : null, reason: stringOrNull(value.reason), startTime: stringOrNull(value.startTime), @@ -208,6 +211,33 @@ function compactTekton(tekton) { }; } +function compactPipeline(pipeline) { + const value = recordOrNull(pipeline); + if (value === null) return null; + return { + metadata: recordOrNull(value.metadata), + spec: recordOrNull(value.spec), + }; +} + +function compactRefreshEvidence(refresh) { + const value = recordOrNull(refresh); + const summary = recordOrNull(value?.summary); + if (value === null || summary === null) return null; + return { + jobName: stringOrNull(value.jobName) ?? stringOrNull(summary.jobName), + namespace: stringOrNull(value.namespace) ?? stringOrNull(summary.namespace), + status: stringOrNull(summary.status), + pipeline: stringOrNull(summary.pipeline), + sourceCommit: stringOrNull(summary.sourceCommit), + sourceStageRef: stringOrNull(summary.sourceStageRef), + elapsedMs: numberOrNull(summary.elapsedMs), + sourceAuthority: stringOrNull(summary.sourceAuthority), + statusAuthority: stringOrNull(summary.statusAuthority), + parsedDownstreamCliOutput: false, + }; +} + function compactArgo(argo) { const value = recordOrNull(argo); if (value === null) return null; diff --git a/scripts/src/cicd-branch-follower.ts b/scripts/src/cicd-branch-follower.ts index 8d4b5254..3778fa7e 100644 --- a/scripts/src/cicd-branch-follower.ts +++ b/scripts/src/cicd-branch-follower.ts @@ -25,6 +25,7 @@ import { renderControllerManifests, renderControllerReconcileJob, waitForJobShel import { buildDebugStep } from "./cicd-debug"; import { runNativeHwlabControlPlaneRefresh } from "./cicd-hwlab-refresh"; import { nativeCicdScriptLoadShell, readNativeObjectBundle } from "./cicd-native-bundle"; +import { compactRefreshEvidence, followerEvidenceSummary } from "./cicd-evidence"; import { runNativeK8sJob, runNativeTektonPipelineRun } from "./cicd-native"; import { argoApplicationReady, nativeArgoSummary, nativeGitMirrorReady, nativeGitMirrorRequired, nativeGitMirrorSummary, nativePipelineRunSummary, nativeRuntimeSummary, pipelineRunSucceeded, runtimeTargetShaFromWorkloads, runtimeWorkloadsReady } from "./cicd-native-summary"; import { invalidRuntimeReuseConfig, missingRuntimeReuseConfig, parseRuntimeReuseConfig, RUNTIME_REUSE_CONFIG_PATH, runtimeReuseService, summarizeRuntimeReuseConfig, type RuntimeReuseConfig } from "./cicd-reuse-config"; @@ -1884,6 +1885,7 @@ async function readAdapterStatus(registry: BranchFollowerRegistry, follower: Fol reuseConfig: observedSha === null ? null : summarizeRuntimeReuseConfig(requireFollowerRuntimeReuseConfig(follower, observedSha, Math.min(timeoutSeconds, 5))), gitMirror: nativeGitMirrorSummary(bundle.gitMirror), tekton: nativePipelineRunSummary(bundle.pipelineRun), + pipeline: bundle.pipeline, taskRuns: bundle.taskRuns, planArtifacts: bundle.planArtifacts, argo: nativeArgoSummary(bundle.argoApplication), @@ -1950,6 +1952,11 @@ function mergeFollowerStatus( const lastTriggeredSha = live?.lastTriggeredSha ?? stringOrNull(stored.lastTriggeredSha); const lastSucceededSha = live?.lastSucceededSha ?? stringOrNull(stored.lastSucceededSha); const timings = live === null ? storedFollowerTimingsForStatus(follower, asOptionalRecord(stored.timings), phase, observedSha) : buildFollowerTimings(follower, live, undefined, asOptionalRecord(stored.timings), phase); + const evidence = followerEvidenceSummary({ + observedSha, + livePayload: asOptionalRecord(live?.payload), + storedCommand: asOptionalRecord(stored.command), + }); const reconcileTimeline = compactReconcileTimeline(asOptionalRecord(stored.command)?.reconcileTimeline, follower.id) ?? { bounded: true, missingReason: "stored state lacks reconcileTimeline; old data cannot be reconstructed", @@ -1981,6 +1988,7 @@ function mergeFollowerStatus( live: liveRequested, message: live?.message ?? stringOrNull(stored.decision) ?? "no controller state yet", timings: detailed ? timings : compactListTimings(timings), + evidence: detailed ? evidence : null, reconcileTimeline: detailed ? reconcileTimeline : null, drilldown: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id} --live`, }; @@ -2285,10 +2293,12 @@ function compactNativePayload(payload: Record | null): Record | null): Record | null): Record | null): Record | null { + const pipeline = asOptionalRecord(value); + if (pipeline === null) return null; + return { + metadata: asOptionalRecord(pipeline.metadata), + spec: asOptionalRecord(pipeline.spec), + }; +} + +function compactRefreshEvidence(value: unknown): Record | null { + const refresh = asOptionalRecord(value); + if (refresh === null) return null; + return { + jobName: stringOrNull(refresh.jobName), + namespace: stringOrNull(refresh.namespace), + status: stringOrNull(refresh.status), + pipeline: stringOrNull(refresh.pipeline), + sourceCommit: stringOrNull(refresh.sourceCommit), + sourceStageRef: stringOrNull(refresh.sourceStageRef), + elapsedMs: numberOrNull(refresh.elapsedMs), + sourceAuthority: stringOrNull(refresh.sourceAuthority), + statusAuthority: stringOrNull(refresh.statusAuthority), + parsedDownstreamCliOutput: false, + }; +} + function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; } diff --git a/scripts/src/cicd-drilldown-render.ts b/scripts/src/cicd-drilldown-render.ts index 75b2a589..c208602d 100644 --- a/scripts/src/cicd-drilldown-render.ts +++ b/scripts/src/cicd-drilldown-render.ts @@ -268,6 +268,26 @@ function nativeGateRows(native: Record | null): unknown[][] { const status = runtime.ready === true ? (runtime.aligned === true ? "ready/aligned" : "ready/stale") : "not-ready"; rows.push(["runtime", status, `${shortSha(stringOrNull(runtime.targetSha))}/${shortSha(stringOrNull(runtime.expectedSha))}`, stringOrNull(runtime.namespace) ?? "-"]); } + const pipeline = asOptionalRecord(native.pipeline); + if (pipeline !== null) { + const runtimeReady = asOptionalRecord(asOptionalRecord(pipeline.spec)?.runtimeReadyTask); + const when = arrayRecords(runtimeReady?.when)[0]; + rows.push([ + "pipeline", + runtimeReady?.present === true ? "runtime-ready-present" : "runtime-ready-absent", + when === undefined ? "-" : `${stringOrNull(when.input) ?? "-"} ${stringOrNull(when.operator) ?? "-"} ${arrayTextItems(when.values).join(",") || "-"}`, + stringOrNull(asOptionalRecord(pipeline.metadata)?.name) ?? "-", + ]); + } + const refresh = asOptionalRecord(native.refreshEvidence); + if (refresh !== null) { + rows.push([ + "control-plane-refresh", + stringOrNull(refresh.status) ?? "-", + `${shortSha(stringOrNull(refresh.sourceCommit))}/${stringOrNull(refresh.pipeline) ?? "-"}`, + stringOrNull(refresh.jobName) ?? "-", + ]); + } for (const error of arrayTextItems(native.errors).slice(0, 5)) rows.push(["error", "present", error, "-"]); return rows; } diff --git a/scripts/src/cicd-evidence.ts b/scripts/src/cicd-evidence.ts new file mode 100644 index 00000000..6259e195 --- /dev/null +++ b/scripts/src/cicd-evidence.ts @@ -0,0 +1,63 @@ +// SPEC: PJ2026-01060703 CI/CD branch follower bounded evidence helpers. +// Responsibility: compact pipeline/runtime-ready and refresh evidence for status/drill-down output. + +export function compactRefreshEvidence(value: Record | null): Record | null { + if (value === null) return null; + const summary = asOptionalRecord(value.summary); + if (summary === null) return null; + return { + jobName: stringOrNull(value.jobName) ?? stringOrNull(summary.jobName), + namespace: stringOrNull(value.namespace) ?? stringOrNull(summary.namespace), + status: stringOrNull(summary.status), + pipeline: stringOrNull(summary.pipeline), + sourceCommit: stringOrNull(summary.sourceCommit), + sourceStageRef: stringOrNull(summary.sourceStageRef), + elapsedMs: numberOrNull(summary.elapsedMs), + sourceAuthority: stringOrNull(summary.sourceAuthority), + statusAuthority: stringOrNull(summary.statusAuthority), + parsedDownstreamCliOutput: false, + }; +} + +export function followerEvidenceSummary(input: { + observedSha: string | null; + livePayload: Record | null; + storedCommand: Record | null; +}): Record | null { + const livePayload = input.livePayload; + const storedPayload = asOptionalRecord(input.storedCommand?.payload); + const payload = livePayload ?? storedPayload; + if (payload === null) return null; + const tekton = asOptionalRecord(payload.tekton); + const pipeline = asOptionalRecord(payload.pipeline); + const refresh = asOptionalRecord(payload.refreshEvidence); + if (tekton === null && pipeline === null && refresh === null) return null; + const pipelineRefName = stringOrNull(tekton?.pipelineRefName); + const pipelineName = stringOrNull(asOptionalRecord(pipeline?.metadata)?.name); + const refreshPipeline = stringOrNull(refresh?.pipeline); + const refreshSourceCommit = stringOrNull(refresh?.sourceCommit); + return { + pipelineRunRefName: pipelineRefName, + pipeline, + refresh: refresh === null + ? null + : { + ...refresh, + pipelineRefMatches: pipelineRefName === null || refreshPipeline === null ? null : pipelineRefName === refreshPipeline, + pipelineSpecMatches: pipelineName === null || refreshPipeline === null ? null : pipelineName === refreshPipeline, + sourceCommitMatches: input.observedSha === null || refreshSourceCommit === null ? null : input.observedSha === refreshSourceCommit, + }, + }; +} + +function asOptionalRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/scripts/src/cicd-native-bundle.ts b/scripts/src/cicd-native-bundle.ts index f9e61d4d..de62e389 100644 --- a/scripts/src/cicd-native-bundle.ts +++ b/scripts/src/cicd-native-bundle.ts @@ -59,6 +59,7 @@ export function readNativeObjectBundle(registry: BranchFollowerRegistry, followe source: sourceRecord, gitMirror: asOptionalRecord(parsed.objects.gitMirror), pipelineRun: asOptionalRecord(parsed.objects.pipelineRun), + pipeline: asOptionalRecord(parsed.objects.pipeline), taskRuns: asOptionalRecord(parsed.objects.taskRuns), planArtifacts: asOptionalRecord(parsed.objects.planArtifacts), argoApplication: asOptionalRecord(parsed.objects.argoApplication), diff --git a/scripts/src/cicd-native-summary.ts b/scripts/src/cicd-native-summary.ts index 4e37b0c4..7d0649d8 100644 --- a/scripts/src/cicd-native-summary.ts +++ b/scripts/src/cicd-native-summary.ts @@ -50,6 +50,7 @@ export function nativePipelineRunSummary(pipelineRun: Record | return { name: stringOrNull(metadata?.name), namespace: stringOrNull(metadata?.namespace), + pipelineRefName: stringOrNull(asOptionalRecord(pipelineRun.spec)?.pipelineRef?.name), succeeded: pipelineRunSucceeded(pipelineRun), reason: stringOrNull(condition?.reason), startTime: stringOrNull(status?.startTime), diff --git a/scripts/src/cicd-native.ts b/scripts/src/cicd-native.ts index 82e09da5..fb27f5c3 100644 --- a/scripts/src/cicd-native.ts +++ b/scripts/src/cicd-native.ts @@ -52,6 +52,7 @@ export function runNativeK8sJob(namespace: string, jobName: string, manifest: Re polls: numberOrNull(parsed?.polls) ?? 0, elapsedMs: numberOrNull(parsed?.elapsedMs) ?? 0, logsTail: stringOrNull(parsed?.logsTail), + summary: asOptionalRecord(parsed?.summary), conditionReason: stringOrNull(parsed?.conditionReason), conditionMessage: stringOrNull(parsed?.conditionMessage) ?? (result.exitCode === 0 ? null : tailText(result.stderr || result.stdout, 500)), statusAuthority: "kubernetes-api-serviceaccount", @@ -70,6 +71,10 @@ function parseJsonObject(text: string): Record | null { } } +function asOptionalRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } diff --git a/scripts/src/cicd-render.ts b/scripts/src/cicd-render.ts index 3646441e..9dbbcd39 100644 --- a/scripts/src/cicd-render.ts +++ b/scripts/src/cicd-render.ts @@ -110,6 +110,7 @@ function renderStatusHuman(payload: Record, _options: ParsedOpt const next = asOptionalRecord(payload.next); const errors = Array.isArray(payload.errors) ? payload.errors : []; const timingRows = followers.flatMap(timingRowsForFollower).slice(0, 48); + const evidenceRows = followers.flatMap(evidenceRowsForFollower).slice(0, 48); const reconcileRows = followers.flatMap(reconcileRowsForFollower).slice(0, 48); return [ `CI/CD BRANCH-FOLLOWER STATUS (${payload.ok === false ? "degraded" : "ok"})`, @@ -121,6 +122,7 @@ function renderStatusHuman(payload: Record, _options: ParsedOpt "", table(["FOLLOWER", "PHASE", "ADAPTER", "OBSERVED", "TARGET", "TRIGGERED", "SUCCEEDED", "IN_FLIGHT", "BUDGET", "MESSAGE"], rows), timingRows.length === 0 ? "" : `\nSTAGE TIMINGS\n${table(["FOLLOWER", "STAGE", "STATUS", "SECONDS", "BUDGET", "OBJECT"], timingRows)}`, + evidenceRows.length === 0 ? "" : `\nEVIDENCE\n${table(["FOLLOWER", "TYPE", "STATUS", "DETAIL", "OBJECT"], evidenceRows)}`, reconcileRows.length === 0 ? "" : `\nRECONCILE TIMELINE\n${table(["FOLLOWER", "STEP", "STATUS", "SECONDS", "STARTED", "OBJECT"], reconcileRows)}`, errors.length === 0 ? "" : `\nERRORS\n${errors.map((item) => `- ${item}`).join("\n")}`, "", @@ -233,6 +235,28 @@ function reconcileRowsFromRunOnce(payload: Record, followers: R return followers.flatMap(reconcileRowsForFollower); } +function evidenceRowsForFollower(item: Record): unknown[][] { + const evidence = asOptionalRecord(item.evidence); + if (evidence === null) return []; + const rows: unknown[][] = []; + rows.push([item.id, "pipelineRef", "observed", stringOrNull(evidence.pipelineRunRefName) ?? "-", stringOrNull(item.pipelineRun) ?? "-"]); + const pipeline = asOptionalRecord(evidence.pipeline); + if (pipeline !== null) { + const runtimeReady = asOptionalRecord(asOptionalRecord(pipeline.spec)?.runtimeReadyTask); + const when = arrayRecords(runtimeReady?.when)[0]; + rows.push([ + item.id, + "pipelineSpec", + runtimeReady?.present === true ? "runtime-ready-present" : "runtime-ready-absent", + when === undefined ? "-" : `${stringOrNull(when.input) ?? "-"} ${stringOrNull(when.operator) ?? "-"} ${arrayText(when.values) || "-"}`, + stringOrNull(asOptionalRecord(pipeline.metadata)?.name) ?? "-", + ]); + } + const refresh = asOptionalRecord(evidence.refresh); + if (refresh !== null) rows.push([item.id, "refresh", stringOrNull(refresh.status) ?? "-", `${shortSha(stringOrNull(refresh.sourceCommit))}/${boolMatch(refresh.pipelineRefMatches)}/${boolMatch(refresh.pipelineSpecMatches)}`, stringOrNull(refresh.pipeline) ?? "-"]); + return rows; +} + function reconcileRowsForFollower(item: Record): unknown[][] { return reconcileRowsForTimeline(asOptionalRecord(item.reconcileTimeline), stringOrNull(item.id)); } @@ -261,6 +285,10 @@ function formatSeconds(value: number | null): string { return value === null ? "-" : `${value}s`; } +function boolMatch(value: unknown): string { + return value === true ? "match" : value === false ? "mismatch" : "-"; +} + function asOptionalRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } diff --git a/scripts/src/cicd-types.ts b/scripts/src/cicd-types.ts index b23ccdea..97798615 100644 --- a/scripts/src/cicd-types.ts +++ b/scripts/src/cicd-types.ts @@ -222,6 +222,7 @@ export interface NativeObjectBundle { source: Record | null; gitMirror: Record | null; pipelineRun: Record | null; + pipeline: Record | null; taskRuns: Record | null; planArtifacts: Record | null; argoApplication: Record | null; @@ -267,6 +268,7 @@ export interface NativeK8sJobResult { polls: number; elapsedMs: number; logsTail: string | null; + summary: Record | null; conditionReason: string | null; conditionMessage: string | null; statusAuthority: "kubernetes-api-serviceaccount"; From 8281797e56584c03c2f59f0b3120a29643ba3ed5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 4 Jul 2026 02:51:58 +0000 Subject: [PATCH 2/2] fix(cicd): preserve refresh evidence in live status --- scripts/src/cicd-branch-follower.ts | 9 +++++++-- scripts/src/cicd-evidence.ts | 18 +++++++++++++----- scripts/src/cicd-render.ts | 10 +++++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/scripts/src/cicd-branch-follower.ts b/scripts/src/cicd-branch-follower.ts index 3778fa7e..7290b8c2 100644 --- a/scripts/src/cicd-branch-follower.ts +++ b/scripts/src/cicd-branch-follower.ts @@ -635,6 +635,7 @@ async function applyController(registry: BranchFollowerRegistry, options: Parsed async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { let k8s = readK8sState(registry, options); + const beforeRefreshStateByFollower = k8s.stateByFollower; const wantsLive = options.live || (!options.noLive && Object.keys(k8s.stateByFollower).length === 0); const refresh = wantsLive && !options.inCluster ? runControllerReconcileJob(registry, options, { dryRun: true, wait: true, recordState: true }) : null; if (refresh !== null) k8s = readK8sState(registry, options); @@ -644,8 +645,9 @@ async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOpti const detailedFollowers = options.followerId !== null || options.full; for (const follower of selected) { const stored = k8s.stateByFollower[follower.id] ?? {}; + const fallbackStored = refresh === null ? {} : beforeRefreshStateByFollower[follower.id] ?? {}; const live = shouldLive && follower.enabled ? await readAdapterStatus(registry, follower, options) : null; - followers.push(mergeFollowerStatus(registry, follower, stored, live, shouldLive, detailedFollowers)); + followers.push(mergeFollowerStatus(registry, follower, stored, live, shouldLive, detailedFollowers, fallbackStored)); } return { ok: k8s.ok && followers.every((item) => item.ok !== false), @@ -666,6 +668,7 @@ async function buildStatus(registry: BranchFollowerRegistry, options: ParsedOpti async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions): Promise> { if (!options.inCluster) { + const before = readK8sState(registry, options); const refresh = runControllerReconcileJob(registry, options, { dryRun: options.dryRun, wait: true, recordState: true }); const k8s = readK8sState(registry, options); const selected = selectFollowers(registry, options, { includeDisabled: false }); @@ -679,7 +682,7 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions) execution: "k8s-native-reconcile-job", registry: registrySummary(registry), job: refresh, - followers: selected.map((follower) => mergeFollowerStatus(registry, follower, k8s.stateByFollower[follower.id] ?? {}, null, false)), + followers: selected.map((follower) => mergeFollowerStatus(registry, follower, k8s.stateByFollower[follower.id] ?? {}, null, false, false, before.stateByFollower[follower.id] ?? {})), warnings: refresh.ok ? [] : [`reconcile job failed: ${refresh.message}`], next: { status: "bun scripts/cli.ts cicd branch-follower status", @@ -1943,6 +1946,7 @@ function mergeFollowerStatus( live: AdapterSummary | null, liveRequested: boolean, detailed: boolean, + fallbackStored: Record = {}, ): Record { const storedSource = asOptionalRecord(stored.source); const storedTarget = asOptionalRecord(stored.target); @@ -1956,6 +1960,7 @@ function mergeFollowerStatus( observedSha, livePayload: asOptionalRecord(live?.payload), storedCommand: asOptionalRecord(stored.command), + fallbackStoredCommand: asOptionalRecord(fallbackStored.command), }); const reconcileTimeline = compactReconcileTimeline(asOptionalRecord(stored.command)?.reconcileTimeline, follower.id) ?? { bounded: true, diff --git a/scripts/src/cicd-evidence.ts b/scripts/src/cicd-evidence.ts index 6259e195..9bbe097f 100644 --- a/scripts/src/cicd-evidence.ts +++ b/scripts/src/cicd-evidence.ts @@ -23,14 +23,14 @@ export function followerEvidenceSummary(input: { observedSha: string | null; livePayload: Record | null; storedCommand: Record | null; + fallbackStoredCommand?: Record | null; }): Record | null { const livePayload = input.livePayload; const storedPayload = asOptionalRecord(input.storedCommand?.payload); - const payload = livePayload ?? storedPayload; - if (payload === null) return null; - const tekton = asOptionalRecord(payload.tekton); - const pipeline = asOptionalRecord(payload.pipeline); - const refresh = asOptionalRecord(payload.refreshEvidence); + const fallbackStoredPayload = asOptionalRecord(input.fallbackStoredCommand?.payload); + const tekton = firstRecord(asOptionalRecord(livePayload?.tekton), asOptionalRecord(storedPayload?.tekton), asOptionalRecord(fallbackStoredPayload?.tekton)); + const pipeline = firstRecord(asOptionalRecord(livePayload?.pipeline), asOptionalRecord(storedPayload?.pipeline), asOptionalRecord(fallbackStoredPayload?.pipeline)); + const refresh = firstRecord(asOptionalRecord(storedPayload?.refreshEvidence), asOptionalRecord(fallbackStoredPayload?.refreshEvidence), asOptionalRecord(livePayload?.refreshEvidence)); if (tekton === null && pipeline === null && refresh === null) return null; const pipelineRefName = stringOrNull(tekton?.pipelineRefName); const pipelineName = stringOrNull(asOptionalRecord(pipeline?.metadata)?.name); @@ -39,6 +39,7 @@ export function followerEvidenceSummary(input: { return { pipelineRunRefName: pipelineRefName, pipeline, + refreshBoundedReason: refresh === null ? "missing-from-live-and-stored-evidence" : null, refresh: refresh === null ? null : { @@ -50,6 +51,13 @@ export function followerEvidenceSummary(input: { }; } +function firstRecord(...values: Array | null>): Record | null { + for (const value of values) { + if (value !== null) return value; + } + return null; +} + function asOptionalRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } diff --git a/scripts/src/cicd-render.ts b/scripts/src/cicd-render.ts index 9dbbcd39..ec63ccf8 100644 --- a/scripts/src/cicd-render.ts +++ b/scripts/src/cicd-render.ts @@ -253,7 +253,15 @@ function evidenceRowsForFollower(item: Record): unknown[][] { ]); } const refresh = asOptionalRecord(evidence.refresh); - if (refresh !== null) rows.push([item.id, "refresh", stringOrNull(refresh.status) ?? "-", `${shortSha(stringOrNull(refresh.sourceCommit))}/${boolMatch(refresh.pipelineRefMatches)}/${boolMatch(refresh.pipelineSpecMatches)}`, stringOrNull(refresh.pipeline) ?? "-"]); + rows.push([ + item.id, + "refresh", + refresh === null ? "missing" : stringOrNull(refresh.status) ?? "-", + refresh === null + ? stringOrNull(evidence.refreshBoundedReason) ?? "-" + : `${shortSha(stringOrNull(refresh.sourceCommit))}/${boolMatch(refresh.pipelineRefMatches)}/${boolMatch(refresh.pipelineSpecMatches)}`, + refresh === null ? "-" : stringOrNull(refresh.pipeline) ?? "-", + ]); return rows; }