From d7c378bf4b1084c1f5eb365454431e10a7a68449 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 26 Sep 2026 23:05:20 -0700 Subject: [PATCH] fix(release): accept retained v1 validation evidence (#159440) * fix(release): read retained v1 candidate evidence * fix(release): type retained candidate bindings * fix(release): type archived candidate bindings --- scripts/full-release-candidate-contract.d.mts | 21 +++++++++-- scripts/full-release-candidate-contract.mjs | 35 ++++++++++++++----- .../lib/full-release-candidate-reuse.d.mts | 5 +-- scripts/lib/full-release-candidate-reuse.mjs | 3 ++ .../full-release-candidate-contract.test.ts | 34 +++++++++++++++++- 5 files changed, 83 insertions(+), 15 deletions(-) diff --git a/scripts/full-release-candidate-contract.d.mts b/scripts/full-release-candidate-contract.d.mts index 884ea2370ee5..a03e072cc318 100644 --- a/scripts/full-release-candidate-contract.d.mts +++ b/scripts/full-release-candidate-contract.d.mts @@ -22,6 +22,12 @@ export interface FullReleaseCandidateRequest { upgradeSurvivorScenarios: string[]; } +export type RecordedFullReleaseCandidateRequest = + | FullReleaseCandidateRequest + | (Omit & { + schema: "openclaw.full-release-candidate-request/v1"; + }); + export interface FullReleaseCandidateArtifactIdentity { digest: string; expiresAt: string; @@ -88,18 +94,27 @@ export interface FullReleaseCandidateBinding extends Omit { + request: RecordedFullReleaseCandidateRequest; +} + export function buildFullReleaseCandidateRequest( input: FullReleaseCandidateRecord, ): FullReleaseCandidateRequest; export function buildFullReleaseCandidateBinding(input: { artifact: FullReleaseCandidateRecord; manifest: FullReleaseCandidateRecord; -}): FullReleaseCandidateBinding; +}): RecordedFullReleaseCandidateBinding; export function canonicalFullReleaseCandidateRequestJson(value: unknown): string; export function candidateRequestSha256(value: unknown): string; export function fullReleaseCandidateArtifactName(requestSha256: string): string; export function validateFullReleaseCandidateRequest(value: unknown): FullReleaseCandidateRequest; export function validateRecordedFullReleaseCandidateRequest( value: unknown, -): FullReleaseCandidateRequest; -export function validateFullReleaseCandidateBinding(value: unknown): FullReleaseCandidateBinding; +): RecordedFullReleaseCandidateRequest; +export function validateFullReleaseCandidateBinding( + value: unknown, +): RecordedFullReleaseCandidateBinding; diff --git a/scripts/full-release-candidate-contract.mjs b/scripts/full-release-candidate-contract.mjs index d4e3995a0262..d89ceee6536e 100644 --- a/scripts/full-release-candidate-contract.mjs +++ b/scripts/full-release-candidate-contract.mjs @@ -10,6 +10,9 @@ import { parseUpgradeSurvivorScenarios, } from "./lib/upgrade-survivor-policy.mjs"; +// Retained evidence must keep its producer schema so its immutable request and +// manifest digests remain reproducible. Fresh candidate requests stay v2-only. +const RETAINED_FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA = "openclaw.full-release-candidate-request/v1"; const FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA = "openclaw.full-release-candidate-request/v2"; const FULL_RELEASE_CANDIDATE_MANIFEST_SCHEMA = "openclaw.full-release-candidate/v2"; const FULL_RELEASE_CANDIDATE_BINDING_SCHEMA = "openclaw.full-release-candidate-binding/v2"; @@ -161,18 +164,31 @@ export function buildFullReleaseCandidateRequest(input) { export function validateFullReleaseCandidateRequest(value) { const request = validateRecordedFullReleaseCandidateRequest(value); + if (request.schema !== FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA) { + fail("full release candidate request schema is invalid"); + } parseUpgradeSurvivorScenarios(request.upgradeSurvivorScenarios.join(" ")); return request; } export function validateRecordedFullReleaseCandidateRequest(value) { + if (!isRecord(value)) { + fail("full release candidate request must be an object"); + } + const schema = value.schema; + if ( + schema !== RETAINED_FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA && + schema !== FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA + ) { + fail("full release candidate request schema is invalid"); + } exactKeys( value, [ "allowFrozenTargetScenarioOmissions", "allowUnreleasedChangelog", "contractVersions", - "packagePublished", + ...(schema === FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA ? ["packagePublished"] : []), "releaseProfile", "releaseSoak", "repository", @@ -244,20 +260,21 @@ export function validateRecordedFullReleaseCandidateRequest(value) { ) { fail("full release candidate request upgradeSurvivorScenarios are not normalized"); } - if (value.schema !== FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA) { - fail("full release candidate request schema is invalid"); - } return { - schema: value.schema, + schema, repository: repository(value.repository, "full release candidate request repository"), targetSha: sha(value.targetSha, "full release candidate request targetSha"), toolingSha: sha(value.toolingSha, "full release candidate request toolingSha"), releaseProfile, releaseSoak: boolean(value.releaseSoak, "full release candidate request releaseSoak"), - packagePublished: boolean( - value.packagePublished, - "full release candidate request packagePublished", - ), + ...(schema === FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA + ? { + packagePublished: boolean( + value.packagePublished, + "full release candidate request packagePublished", + ), + } + : {}), upgradeSurvivorBaselines: baselines, upgradeSurvivorScenarios: scenarios, allowFrozenTargetScenarioOmissions: boolean( diff --git a/scripts/lib/full-release-candidate-reuse.d.mts b/scripts/lib/full-release-candidate-reuse.d.mts index 7fe028fa58ce..c01d5b07de3e 100644 --- a/scripts/lib/full-release-candidate-reuse.d.mts +++ b/scripts/lib/full-release-candidate-reuse.d.mts @@ -1,6 +1,7 @@ import type { FullReleaseCandidateBinding, FullReleaseCandidateRequest, + RecordedFullReleaseCandidateBinding, } from "../full-release-candidate-contract.mjs"; export class CandidateConstituentUnavailableError extends Error {} @@ -42,7 +43,7 @@ export function validateCandidateBinding( now?: number; request?: FullReleaseCandidateRequest; }, -): FullReleaseCandidateBinding; +): RecordedFullReleaseCandidateBinding; export function resolveCandidateBinding(input: { freshBinding?: unknown; @@ -63,4 +64,4 @@ export function verifySealedFullReleaseCandidate(input: { readRunAttempt: (runId: string, runAttempt: string) => Promise; readWorkflowJobs: (runId: string, runAttempt: string) => Promise; token: string; -}): Promise; +}): Promise; diff --git a/scripts/lib/full-release-candidate-reuse.mjs b/scripts/lib/full-release-candidate-reuse.mjs index 391ec2221784..91e5f143a2e7 100644 --- a/scripts/lib/full-release-candidate-reuse.mjs +++ b/scripts/lib/full-release-candidate-reuse.mjs @@ -310,6 +310,9 @@ export function validateCandidateBinding( export function candidateArtifactJsonFromBinding(value) { const binding = validateFullReleaseCandidateBinding(value); + if (binding.request.packagePublished === undefined) { + fail("retained v1 candidate evidence cannot supply package provenance"); + } return JSON.stringify({ packagePublished: binding.request.packagePublished, packageArtifactName: binding.package.artifact.name, diff --git a/test/scripts/full-release-candidate-contract.test.ts b/test/scripts/full-release-candidate-contract.test.ts index f73ab75829c0..27a32008ae3a 100644 --- a/test/scripts/full-release-candidate-contract.test.ts +++ b/test/scripts/full-release-candidate-contract.test.ts @@ -9,7 +9,10 @@ import { validateFullReleaseCandidateBinding, validateFullReleaseCandidateRequest, } from "../../scripts/full-release-candidate-contract.mjs"; -import { resolveCandidateBinding } from "../../scripts/lib/full-release-candidate-reuse.mjs"; +import { + candidateArtifactJsonFromBinding, + resolveCandidateBinding, +} from "../../scripts/lib/full-release-candidate-reuse.mjs"; import { canonicalTestJson, canonicalTestSha256, @@ -138,6 +141,35 @@ describe("full release candidate contract", () => { } }); + it("validates retained v1 request and manifest digests without rewriting them", () => { + const currentManifest = fullReleaseCandidateManifestFixture(); + const { packagePublished: _packagePublished, ...requestFields } = currentManifest.request; + const request = { + ...requestFields, + schema: "openclaw.full-release-candidate-request/v1" as const, + }; + const requestSha256 = canonicalTestSha256(request); + const retainedManifest = { ...currentManifest, request, requestSha256 }; + const currentBinding = fullReleaseCandidateBindingFixture(); + const binding = { + ...currentBinding, + request, + requestSha256, + evidenceArtifact: { + ...currentBinding.evidenceArtifact, + name: `full-release-candidate-v2-${requestSha256}`, + }, + manifestSha256: canonicalTestSha256(retainedManifest), + }; + + expect(validateFullReleaseCandidateBinding(binding)).toEqual(binding); + expect(candidateRequestSha256(request)).toBe(requestSha256); + expect(() => validateFullReleaseCandidateRequest(request)).toThrow("schema is invalid"); + expect(() => candidateArtifactJsonFromBinding(binding)).toThrow( + "retained v1 candidate evidence cannot supply package provenance", + ); + }); + it.each([ ["repository", { repository: "openclaw/fork" }], ["target SHA", { targetSha: "4".repeat(40) }],