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
This commit is contained in:
Dallin Romney 2026-09-26 23:05:20 -07:00 • committed by GitHub
parent 4a2115c767
commit d7c378bf4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 83 additions and 15 deletions

View file

@ -22,6 +22,12 @@ export interface FullReleaseCandidateRequest {
upgradeSurvivorScenarios: string[];
}
export type RecordedFullReleaseCandidateRequest =
| FullReleaseCandidateRequest
| (Omit<FullReleaseCandidateRequest, "packagePublished" | "schema"> & {
schema: "openclaw.full-release-candidate-request/v1";
});
export interface FullReleaseCandidateArtifactIdentity {
digest: string;
expiresAt: string;
@ -88,18 +94,27 @@ export interface FullReleaseCandidateBinding extends Omit<FullReleaseCandidateMa
schema: "openclaw.full-release-candidate-binding/v2";
}
export interface RecordedFullReleaseCandidateBinding extends Omit<
FullReleaseCandidateBinding,
"request"
> {
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;

View file

@ -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(

View file

@ -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<unknown>;
readWorkflowJobs: (runId: string, runAttempt: string) => Promise<unknown>;
token: string;
}): Promise<FullReleaseCandidateBinding>;
}): Promise<RecordedFullReleaseCandidateBinding>;

View file

@ -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,

View file

@ -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) }],