mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
835 lines
30 KiB
TypeScript
835 lines
30 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { runInNewContext } from "node:vm";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { parse } from "yaml";
|
|
import {
|
|
dockerReleaseArtifactName,
|
|
preparedDockerEvidenceFromFullRelease,
|
|
publishDockerRelease,
|
|
sealDockerRelease,
|
|
validateDockerReleaseIdentity,
|
|
validateDockerReleaseManifest,
|
|
verifyDockerReleaseLayout,
|
|
verifyDockerReleaseProducer,
|
|
} from "../../scripts/docker-release-artifacts.mjs";
|
|
|
|
const sourceSha = "a".repeat(40);
|
|
const toolingSha = "b".repeat(40);
|
|
const repository = "openclaw/openclaw";
|
|
const runId = "100";
|
|
const runAttempt = "2";
|
|
const roots: string[] = [];
|
|
const mediaType = "application/vnd.oci.image.manifest.v1+json";
|
|
const indexMediaType = "application/vnd.oci.image.index.v1+json";
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function temporaryDirectory() {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-docker-artifacts-"));
|
|
roots.push(root);
|
|
return root;
|
|
}
|
|
|
|
function writeJson(file: string, value: unknown) {
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
writeFileSync(file, JSON.stringify(value));
|
|
}
|
|
|
|
function createLayout(
|
|
directory: string,
|
|
architecture: string,
|
|
options: { labelSha?: string; provenance?: boolean; version?: string } = {},
|
|
) {
|
|
function blob(value: unknown) {
|
|
const bytes = Buffer.from(JSON.stringify(value));
|
|
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
const file = path.join(directory, "blobs", "sha256", digest);
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
writeFileSync(file, bytes);
|
|
return { digest: `sha256:${digest}`, size: bytes.length };
|
|
}
|
|
const config = blob({
|
|
architecture,
|
|
os: "linux",
|
|
rootfs: { type: "layers", diff_ids: [] },
|
|
config: {
|
|
Labels: {
|
|
"org.opencontainers.image.revision": options.labelSha ?? sourceSha,
|
|
"org.opencontainers.image.version": options.version ?? "2026.8.1-beta.2",
|
|
"org.opencontainers.image.created": "2026-09-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
const image = blob({
|
|
schemaVersion: 2,
|
|
mediaType,
|
|
config: { mediaType: "application/vnd.oci.image.config.v1+json", ...config },
|
|
layers: [],
|
|
});
|
|
const predicates = [
|
|
"https://spdx.dev/Document",
|
|
...(options.provenance === false ? [] : ["https://slsa.dev/provenance/v1"]),
|
|
];
|
|
const attestation = blob({
|
|
schemaVersion: 2,
|
|
mediaType,
|
|
config: { mediaType: "application/vnd.oci.image.config.v1+json", ...blob({}) },
|
|
layers: predicates.map((predicateType) =>
|
|
Object.assign(
|
|
blob({ predicateType, subject: [{ digest: { sha256: image.digest.slice(7) } }] }),
|
|
{
|
|
mediaType: "application/vnd.in-toto+json",
|
|
annotations: { "in-toto.io/predicate-type": predicateType },
|
|
},
|
|
),
|
|
),
|
|
});
|
|
const manifests = [
|
|
{ mediaType, ...image, platform: { os: "linux", architecture } },
|
|
{
|
|
mediaType,
|
|
...attestation,
|
|
platform: { os: "unknown", architecture: "unknown" },
|
|
annotations: {
|
|
"vnd.docker.reference.type": "attestation-manifest",
|
|
"vnd.docker.reference.digest": image.digest,
|
|
},
|
|
},
|
|
];
|
|
const index = blob({ schemaVersion: 2, mediaType: indexMediaType, manifests });
|
|
writeJson(path.join(directory, "oci-layout"), { imageLayoutVersion: "1.0.0" });
|
|
writeJson(path.join(directory, "index.json"), {
|
|
schemaVersion: 2,
|
|
manifests: [{ mediaType: indexMediaType, ...index }],
|
|
});
|
|
return {
|
|
indexDigest: index.digest,
|
|
imageDigest: image.digest,
|
|
configDigest: config.digest,
|
|
manifests,
|
|
};
|
|
}
|
|
|
|
async function createPreparedRelease(includeBrowser = true, version = "2026.8.1-beta.2") {
|
|
const root = temporaryDirectory();
|
|
const artifactName = dockerReleaseArtifactName(sourceSha, runAttempt);
|
|
const context = {
|
|
schemaVersion: 1,
|
|
repository,
|
|
sourceSha,
|
|
toolingSha,
|
|
tag: `v${version}`,
|
|
version,
|
|
imageTagSuffix: "-r20260901",
|
|
builtAt: "2026-09-01T00:00:00.000Z",
|
|
includeBrowser,
|
|
producer: {
|
|
runId,
|
|
runAttempt,
|
|
workflowRef: `${repository}/.github/workflows/full-release-validation.yml@refs/heads/main`,
|
|
workflowSha: toolingSha,
|
|
preparationWorkflowRef: `${repository}/.github/workflows/docker-release-prepare.yml@refs/heads/main`,
|
|
},
|
|
};
|
|
const artifacts = ["amd64", "arm64"].map((architecture, index) => ({
|
|
id: index + 10,
|
|
name: `${artifactName}-${architecture}`,
|
|
digest: `sha256:${String(index + 1).repeat(64)}`,
|
|
expired: false,
|
|
size_in_bytes: 1024,
|
|
workflow_run: { id: Number(runId), head_sha: toolingSha },
|
|
}));
|
|
const job = {
|
|
id: 7,
|
|
run_id: Number(runId),
|
|
run_attempt: Number(runAttempt),
|
|
name: "Prepare Docker / Seal prepared Docker images",
|
|
check_run_url: `https://api.github.com/repos/${repository}/check-runs/7`,
|
|
status: "completed",
|
|
conclusion: "success",
|
|
head_sha: toolingSha,
|
|
};
|
|
const run = {
|
|
id: Number(runId),
|
|
run_attempt: Number(runAttempt),
|
|
path: ".github/workflows/full-release-validation.yml",
|
|
head_branch: "main",
|
|
head_sha: toolingSha,
|
|
event: "workflow_dispatch",
|
|
status: "in_progress",
|
|
conclusion: null as string | null,
|
|
repository: { full_name: repository },
|
|
head_repository: { full_name: repository },
|
|
referenced_workflows: [
|
|
{
|
|
path: `${repository}/.github/workflows/docker-release-prepare.yml@${toolingSha}`,
|
|
sha: toolingSha,
|
|
ref: "refs/heads/main",
|
|
},
|
|
],
|
|
};
|
|
const attemptRun = structuredClone(run);
|
|
const readApi = (endpoint: string) => {
|
|
if (endpoint.includes("/artifacts?")) {
|
|
return { artifacts: artifacts.filter((artifact) => endpoint.includes(artifact.name)) };
|
|
}
|
|
if (endpoint.includes("/compare/")) {
|
|
return { status: "identical" };
|
|
}
|
|
if (endpoint.endsWith("/actions/jobs/7")) {
|
|
return job;
|
|
}
|
|
if (endpoint.includes("/jobs?")) {
|
|
return {
|
|
jobs: [
|
|
job,
|
|
{
|
|
...job,
|
|
id: 8,
|
|
check_run_url: `https://api.github.com/repos/${repository}/check-runs/8`,
|
|
},
|
|
],
|
|
total_count: 2,
|
|
};
|
|
}
|
|
if (endpoint.endsWith(`/actions/runs/${runId}/attempts/${runAttempt}`)) {
|
|
return attemptRun;
|
|
}
|
|
if (endpoint.endsWith(`/actions/runs/${runId}`)) {
|
|
return run;
|
|
}
|
|
throw new Error(`Unexpected API read: ${endpoint}`);
|
|
};
|
|
for (const architecture of ["amd64", "arm64"]) {
|
|
const images = [];
|
|
for (const variant of includeBrowser ? ["default", "browser"] : ["default"]) {
|
|
const directory = path.join(root, "payloads", `${artifactName}-${architecture}`, variant);
|
|
const image = createLayout(directory, architecture, { version });
|
|
const verified = await verifyDockerReleaseLayout({
|
|
...context,
|
|
directory,
|
|
architecture,
|
|
expectedDigest: image.indexDigest,
|
|
});
|
|
images.push({ variant, ...verified, smoke: "success", attestations: "success" });
|
|
}
|
|
writeJson(path.join(root, "metadata", `${architecture}.json`), {
|
|
...context,
|
|
architecture,
|
|
images,
|
|
});
|
|
}
|
|
const manifest = sealDockerRelease({
|
|
metadataDirectory: path.join(root, "metadata"),
|
|
context,
|
|
checkRunId: "7",
|
|
readApi,
|
|
});
|
|
return { root, manifest, run, attemptRun, job, artifacts, readApi };
|
|
}
|
|
|
|
async function createPublicationRetry(conclusion = "failure") {
|
|
const fixture = await createPreparedRelease(false);
|
|
const workflow = ".github/workflows/openclaw-release-publish.yml";
|
|
fixture.manifest.producer.workflowRef = `${repository}/${workflow}@refs/heads/main`;
|
|
fixture.run.path = fixture.attemptRun.path = workflow;
|
|
fixture.attemptRun.status = "completed";
|
|
fixture.attemptRun.conclusion = conclusion;
|
|
fixture.run.run_attempt += 1;
|
|
fixture.run.referenced_workflows = [];
|
|
return {
|
|
...fixture,
|
|
publisher: {
|
|
publisherSha: toolingSha,
|
|
publisherRunId: runId,
|
|
publisherRunAttempt: String(fixture.run.run_attempt),
|
|
readApi: fixture.readApi,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("prepared Docker publication", () => {
|
|
it.each(["full-release-validation", "full-release-artifacts"])(
|
|
"binds native builds to exact artifacts and the %s seal job",
|
|
async (workflow) => {
|
|
const fixture = await createPreparedRelease();
|
|
fixture.run.path = `.github/workflows/${workflow}.yml`;
|
|
fixture.manifest.producer.workflowRef = `${repository}/${fixture.run.path}@refs/heads/main`;
|
|
const manifest = verifyDockerReleaseProducer(fixture.manifest, {
|
|
publisherSha: toolingSha,
|
|
readApi: fixture.readApi,
|
|
});
|
|
expect(manifest.producer.jobId).toBe("7");
|
|
expect(
|
|
manifest.architectures.map((entry: { architecture: string }) => entry.architecture),
|
|
).toEqual(["amd64", "arm64"]);
|
|
expect(
|
|
manifest.architectures.flatMap((entry: { images: { variant: string }[] }) =>
|
|
entry.images.map((image) => image.variant),
|
|
),
|
|
).toEqual(["default", "browser", "default", "browser"]);
|
|
fixture.run.status = "completed";
|
|
fixture.run.conclusion = "success";
|
|
expect(
|
|
verifyDockerReleaseProducer(manifest, {
|
|
publisherSha: toolingSha,
|
|
readApi: fixture.readApi,
|
|
}),
|
|
).toBe(manifest);
|
|
},
|
|
);
|
|
|
|
it.each(["failure", "cancelled", "timed_out"])(
|
|
"reuses its successful preparation when retrying a %s publication attempt",
|
|
async (conclusion) => {
|
|
const fixture = await createPublicationRetry(conclusion);
|
|
expect(verifyDockerReleaseProducer(fixture.manifest, fixture.publisher)).toBe(
|
|
fixture.manifest,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
"unrelated publisher",
|
|
"stale publisher attempt",
|
|
"different publisher source",
|
|
"failed current parent",
|
|
"changed historical source",
|
|
"wrong historical attempt",
|
|
"changed historical workflow",
|
|
"missing historical preparation",
|
|
"failed seal",
|
|
"replaced artifact",
|
|
])("rejects a publication retry with %s", async (failure) => {
|
|
const fixture = await createPublicationRetry();
|
|
if (failure === "unrelated publisher") {
|
|
fixture.publisher.publisherRunId = "200";
|
|
}
|
|
if (failure === "stale publisher attempt") {
|
|
fixture.publisher.publisherRunAttempt = runAttempt;
|
|
}
|
|
if (failure === "different publisher source") {
|
|
fixture.publisher.publisherSha = sourceSha;
|
|
}
|
|
if (failure === "failed current parent") {
|
|
fixture.run.status = "completed";
|
|
fixture.run.conclusion = "failure";
|
|
}
|
|
if (failure === "changed historical source") {
|
|
fixture.attemptRun.head_sha = sourceSha;
|
|
}
|
|
if (failure === "wrong historical attempt") {
|
|
fixture.attemptRun.run_attempt += 1;
|
|
}
|
|
if (failure === "changed historical workflow") {
|
|
fixture.attemptRun.path = ".github/workflows/ci.yml";
|
|
}
|
|
if (failure === "missing historical preparation") {
|
|
fixture.attemptRun.referenced_workflows = [];
|
|
}
|
|
if (failure === "failed seal") {
|
|
fixture.job.conclusion = "failure";
|
|
}
|
|
if (failure === "replaced artifact") {
|
|
fixture.artifacts[0]!.id += 100;
|
|
}
|
|
expect(() => verifyDockerReleaseProducer(fixture.manifest, fixture.publisher)).toThrow();
|
|
});
|
|
|
|
it("retains successful historical preparation for an unrelated publisher", async () => {
|
|
const fixture = await createPublicationRetry("success");
|
|
fixture.publisher.publisherRunId = "200";
|
|
expect(verifyDockerReleaseProducer(fixture.manifest, fixture.publisher)).toBe(fixture.manifest);
|
|
});
|
|
|
|
it.each([
|
|
"unfinished job",
|
|
"failed parent",
|
|
"unfinished historical attempt",
|
|
"different tooling",
|
|
"replaced artifact",
|
|
"wrong workflow",
|
|
])("rejects %s evidence", async (failure) => {
|
|
const fixture = await createPreparedRelease(false);
|
|
if (failure === "unfinished job") {
|
|
fixture.job.status = "in_progress";
|
|
}
|
|
if (failure === "failed parent") {
|
|
fixture.run.status = "completed";
|
|
fixture.run.conclusion = "failure";
|
|
}
|
|
if (failure === "unfinished historical attempt") {
|
|
fixture.run.run_attempt += 1;
|
|
}
|
|
if (failure === "different tooling") {
|
|
fixture.run.referenced_workflows[0]!.sha = sourceSha;
|
|
}
|
|
if (failure === "replaced artifact") {
|
|
fixture.artifacts[0]!.id += 100;
|
|
}
|
|
if (failure === "wrong workflow") {
|
|
fixture.run.path = ".github/workflows/ci.yml";
|
|
}
|
|
expect(() =>
|
|
verifyDockerReleaseProducer(fixture.manifest, {
|
|
publisherSha: toolingSha,
|
|
readApi: fixture.readApi,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
it("preserves scheduled stable and extended-stable image refresh preparation", async () => {
|
|
const fixture = await createPreparedRelease(false);
|
|
fixture.manifest.producer.workflowRef = `${repository}/.github/workflows/docker-image-refresh.yml@refs/heads/main`;
|
|
fixture.run.path = ".github/workflows/docker-image-refresh.yml";
|
|
fixture.run.event = "schedule";
|
|
expect(
|
|
verifyDockerReleaseProducer(fixture.manifest, {
|
|
publisherSha: toolingSha,
|
|
readApi: fixture.readApi,
|
|
}),
|
|
).toBe(fixture.manifest);
|
|
});
|
|
|
|
it("rejects missing provenance and mismatched source labels in actual OCI bytes", async () => {
|
|
for (const options of [{ provenance: false }, { labelSha: toolingSha }]) {
|
|
const directory = temporaryDirectory();
|
|
const image = createLayout(directory, "amd64", options);
|
|
await expect(
|
|
verifyDockerReleaseLayout({
|
|
directory,
|
|
architecture: "amd64",
|
|
sourceSha,
|
|
version: "2026.8.1-beta.2",
|
|
builtAt: "2026-09-01T00:00:00.000Z",
|
|
expectedDigest: image.indexDigest,
|
|
}),
|
|
).rejects.toThrow(/missing predicate|labels/);
|
|
}
|
|
});
|
|
|
|
it("validates every payload before allowing the first registry mutation", async () => {
|
|
const { root, manifest } = await createPreparedRelease();
|
|
const arm = manifest.architectures[1];
|
|
const digest = arm.images[0].configDigest;
|
|
writeFileSync(
|
|
path.join(root, "payloads", arm.artifact.name, "default", "blobs", "sha256", digest.slice(7)),
|
|
"corrupt",
|
|
);
|
|
const execute = vi.fn();
|
|
await expect(
|
|
publishDockerRelease({
|
|
manifest,
|
|
payloadDirectory: path.join(root, "payloads"),
|
|
images: ["ghcr.io/openclaw/openclaw"],
|
|
execFileSyncImpl: execute,
|
|
verifyTag: vi.fn(),
|
|
}),
|
|
).rejects.toThrow("OCI blob size/type mismatch");
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("copies preserved indexes to both registries and emits verified immutable mirror inputs", async () => {
|
|
const { root, manifest } = await createPreparedRelease();
|
|
const indexes = new Map<string, { digest: string; manifests: unknown[] }>();
|
|
for (const entry of manifest.architectures) {
|
|
for (const image of entry.images) {
|
|
indexes.set(image.indexDigest, { digest: image.indexDigest, manifests: image.manifests });
|
|
}
|
|
}
|
|
const tags = new Map<string, string>();
|
|
const calls: string[][] = [];
|
|
const execute = vi.fn((command: string, args: readonly string[]) => {
|
|
calls.push([command, ...args]);
|
|
if (command === "skopeo") {
|
|
const directory = args[3]!.slice(4);
|
|
const descriptor = JSON.parse(readFileSync(path.join(directory, "index.json"), "utf8"))
|
|
.manifests[0];
|
|
tags.set(args[4]!.slice("docker://".length), descriptor.digest);
|
|
return "";
|
|
}
|
|
if (args[2] === "create") {
|
|
const refs = args.filter((arg) => arg.includes("@sha256:"));
|
|
let digest = refs[0]!.split("@")[1]!;
|
|
if (refs.length > 1) {
|
|
const descriptors = refs.flatMap((ref) => indexes.get(ref.split("@")[1]!)!.manifests);
|
|
digest = `sha256:${createHash("sha256").update(JSON.stringify(descriptors)).digest("hex")}`;
|
|
indexes.set(digest, { digest, manifests: descriptors });
|
|
}
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
if (args[index] === "--tag") {
|
|
tags.set(args[index + 1]!, digest);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
if (args.includes("--format")) {
|
|
return JSON.stringify({ digest: tags.get(args[3]!) });
|
|
}
|
|
const digest = args[4]!.split("@")[1]!;
|
|
const index = indexes.get(digest);
|
|
if (index) {
|
|
return JSON.stringify({
|
|
schemaVersion: 2,
|
|
mediaType: indexMediaType,
|
|
manifests: index.manifests,
|
|
});
|
|
}
|
|
for (const entry of manifest.architectures) {
|
|
for (const image of entry.images) {
|
|
const descriptor = image.manifests.find(
|
|
(candidate: { digest: string }) => candidate.digest === digest,
|
|
);
|
|
if (descriptor) {
|
|
return readFileSync(
|
|
path.join(
|
|
root,
|
|
"payloads",
|
|
entry.artifact.name,
|
|
image.variant,
|
|
"blobs",
|
|
"sha256",
|
|
digest.slice(7),
|
|
),
|
|
"utf8",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
throw new Error(`Unexpected Docker command: ${args.join(" ")}`);
|
|
});
|
|
const verifyTag = vi.fn();
|
|
const promote = vi.fn();
|
|
const output = await publishDockerRelease({
|
|
manifest,
|
|
payloadDirectory: path.join(root, "payloads"),
|
|
images: ["ghcr.io/openclaw/openclaw", "docker.io/openclaw/openclaw"],
|
|
execFileSyncImpl: execute,
|
|
verifyTag,
|
|
promote,
|
|
});
|
|
expect(verifyTag).toHaveBeenCalledExactlyOnceWith(manifest);
|
|
expect(calls.filter((call) => call[0] === "skopeo")).toHaveLength(8);
|
|
expect(
|
|
calls
|
|
.filter((call) => call[0] === "skopeo")
|
|
.every((call) => call[2] === "--all" && call[3] === "--preserve-digests"),
|
|
).toBe(true);
|
|
expect(tags.size).toBe(18);
|
|
expect([...tags.keys()].every((tag) => tag.includes("2026.8.1-beta.2-r20260901"))).toBe(true);
|
|
expect(output.split("\n")).toEqual(
|
|
["default", "slim", "browser"].map(
|
|
(variant) =>
|
|
`${variant}=${tags.get(`ghcr.io/openclaw/openclaw:2026.8.1-beta.2-r20260901${variant === "default" ? "" : `-${variant}`}`)}`,
|
|
),
|
|
);
|
|
expect(promote).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a copy that cannot preserve its original digest", async () => {
|
|
const { root, manifest } = await createPreparedRelease(false);
|
|
const execute = vi.fn((command: string) =>
|
|
command === "skopeo" ? "" : JSON.stringify({ digest: `sha256:${"f".repeat(64)}` }),
|
|
);
|
|
await expect(
|
|
publishDockerRelease({
|
|
manifest,
|
|
payloadDirectory: path.join(root, "payloads"),
|
|
images: ["ghcr.io/openclaw/openclaw"],
|
|
execFileSyncImpl: execute,
|
|
verifyTag: vi.fn(),
|
|
}),
|
|
).rejects.toThrow("did not preserve");
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each([
|
|
["2026.8.1", "v2026.8.1"],
|
|
["2026.8.1", "v2026.8.1-2"],
|
|
["2026.8.1-2", "v2026.8.1-2"],
|
|
] as const)("seals package %s for exactly release %s", async (packageVersion, tag) => {
|
|
const policy = validateDockerReleaseIdentity({ tag, sourceSha, packageVersion });
|
|
expect(policy.channel).toBe("stable");
|
|
expect(policy.version).toBe(tag.slice(1));
|
|
const { manifest } = await createPreparedRelease(false, policy.version);
|
|
const expected = {
|
|
repository,
|
|
sourceSha,
|
|
tag,
|
|
imageTagSuffix: manifest.imageTagSuffix,
|
|
artifactName: manifest.artifactName,
|
|
runId,
|
|
runAttempt,
|
|
};
|
|
expect(validateDockerReleaseManifest(manifest, expected)).toBe(manifest);
|
|
for (const otherTag of ["v2026.8.1", "v2026.8.1-2", "v2026.8.1-3"].filter(
|
|
(candidate) => candidate !== tag,
|
|
)) {
|
|
expect(() => validateDockerReleaseManifest(manifest, { ...expected, tag: otherTag })).toThrow(
|
|
"does not match the release",
|
|
);
|
|
}
|
|
});
|
|
|
|
it("rejects a correction for another package base or an unsupported release train", () => {
|
|
for (const [packageVersion, tag] of [
|
|
["2026.8.1", "v2026.8.11-2"],
|
|
["2026.8.1-2", "v2026.8.1-3"],
|
|
] as const) {
|
|
expect(() => validateDockerReleaseIdentity({ tag, sourceSha, packageVersion })).toThrow(
|
|
"does not match release tag",
|
|
);
|
|
}
|
|
expect(() =>
|
|
validateDockerReleaseIdentity({
|
|
tag: "v2026.8.1-alpha.2",
|
|
sourceSha,
|
|
packageVersion: "2026.8.1-alpha.2",
|
|
}),
|
|
).toThrow("alpha");
|
|
});
|
|
|
|
it.each([
|
|
{ workflow: "full-release-validation", fullRunId: runId, fullRunAttempt: runAttempt },
|
|
{ workflow: "full-release-artifacts", fullRunId: "200", fullRunAttempt: "3" },
|
|
])(
|
|
"resolves $workflow Docker evidence from its selected full release",
|
|
async ({ workflow, fullRunId, fullRunAttempt }) => {
|
|
const fixture = await createPreparedRelease(false);
|
|
const { manifest } = fixture;
|
|
fixture.run.path = `.github/workflows/${workflow}.yml`;
|
|
fixture.run.status = "completed";
|
|
fixture.run.conclusion = "success";
|
|
Object.assign(fixture.attemptRun, structuredClone(fixture.run));
|
|
manifest.producer.workflowRef = `${repository}/${fixture.run.path}@refs/heads/main`;
|
|
const prepared = {
|
|
preparedRunId: runId,
|
|
preparedRunAttempt: runAttempt,
|
|
preparedArtifactName: manifest.artifactName,
|
|
preparedManifestSha256: "c".repeat(64),
|
|
};
|
|
const full = {
|
|
runId: fullRunId,
|
|
runAttempt: Number(fullRunAttempt),
|
|
targetSha: sourceSha,
|
|
publicationArtifacts: { docker: prepared },
|
|
};
|
|
const selection = {
|
|
manifest: full,
|
|
sourceSha,
|
|
runId: fullRunId,
|
|
runAttempt: fullRunAttempt,
|
|
};
|
|
const selected = preparedDockerEvidenceFromFullRelease(selection);
|
|
expect(selected).toBe(prepared);
|
|
const expected = {
|
|
repository,
|
|
sourceSha,
|
|
tag: manifest.tag,
|
|
imageTagSuffix: manifest.imageTagSuffix,
|
|
artifactName: selected.preparedArtifactName,
|
|
runId: selected.preparedRunId,
|
|
runAttempt: selected.preparedRunAttempt,
|
|
};
|
|
expect(validateDockerReleaseManifest(manifest, expected)).toBe(manifest);
|
|
expect(
|
|
verifyDockerReleaseProducer(manifest, {
|
|
publisherSha: toolingSha,
|
|
readApi: fixture.readApi,
|
|
}),
|
|
).toBe(manifest);
|
|
expect(preparedDockerEvidenceFromFullRelease({ ...selection, manifest: {} })).toBeNull();
|
|
for (const mismatch of [{ sourceSha: toolingSha }, { runId: "300" }, { runAttempt: "4" }]) {
|
|
expect(() => preparedDockerEvidenceFromFullRelease({ ...selection, ...mismatch })).toThrow(
|
|
"selected full release",
|
|
);
|
|
}
|
|
for (const invalid of [
|
|
{ preparedRunId: 100 },
|
|
{ preparedRunId: "0" },
|
|
{ preparedRunAttempt: 2 },
|
|
{ preparedRunAttempt: "0" },
|
|
{ preparedRunAttempt: "1" },
|
|
{ preparedArtifactName: dockerReleaseArtifactName(sourceSha, "3") },
|
|
{ preparedManifestSha256: "invalid" },
|
|
]) {
|
|
expect(() =>
|
|
preparedDockerEvidenceFromFullRelease({
|
|
...selection,
|
|
manifest: {
|
|
...full,
|
|
publicationArtifacts: { docker: { ...prepared, ...invalid } },
|
|
},
|
|
}),
|
|
).toThrow("incomplete or stale");
|
|
}
|
|
expect(() => validateDockerReleaseManifest(manifest, { ...expected, runId: "300" })).toThrow(
|
|
"producer identity mismatch",
|
|
);
|
|
fixture.run.run_attempt += 1;
|
|
expect(
|
|
verifyDockerReleaseProducer(manifest, {
|
|
publisherSha: toolingSha,
|
|
readApi: fixture.readApi,
|
|
}),
|
|
).toBe(manifest);
|
|
fixture.attemptRun.conclusion = "failure";
|
|
expect(() =>
|
|
verifyDockerReleaseProducer(manifest, {
|
|
publisherSha: toolingSha,
|
|
readApi: fixture.readApi,
|
|
}),
|
|
).toThrow("Historical Docker producer did not qualify");
|
|
},
|
|
);
|
|
|
|
it("prepares both native architectures with one release identity and smokes before sealing", () => {
|
|
const prepare = parse(readFileSync(".github/workflows/docker-release-prepare.yml", "utf8"));
|
|
const build = prepare.jobs.build;
|
|
const steps = build.steps as {
|
|
id?: string;
|
|
name?: string;
|
|
uses?: string;
|
|
run?: string;
|
|
if?: string;
|
|
"continue-on-error"?: boolean;
|
|
with?: Record<string, unknown>;
|
|
}[];
|
|
expect(build.strategy.matrix.include).toEqual([
|
|
{ architecture: "amd64", runner: "ubuntu-24.04" },
|
|
{ architecture: "arm64", runner: "ubuntu-24.04-arm" },
|
|
]);
|
|
const builders = steps.filter((step) => step.uses?.startsWith("docker/build-push-action@"));
|
|
expect(builders).toHaveLength(2);
|
|
for (const step of builders) {
|
|
expect(step.with).toMatchObject({
|
|
context: "source",
|
|
platforms: "linux/${{ matrix.architecture }}",
|
|
push: false,
|
|
sbom: true,
|
|
provenance: "mode=max",
|
|
});
|
|
expect(String(step.with?.["build-args"]).split("\n")).toEqual(
|
|
expect.arrayContaining([
|
|
"GITHUB_ACTIONS=true",
|
|
"GIT_COMMIT=${{ inputs.release_sha }}",
|
|
"OPENCLAW_BUILD_TIMESTAMP=${{ needs.resolve.outputs.built_at }}",
|
|
"OPENCLAW_DOCKER_BUILD_VERSION=${{ needs.resolve.outputs.version }}",
|
|
"OPENCLAW_EXTENSIONS=diagnostics-otel,codex",
|
|
]),
|
|
);
|
|
expect(String(step.with?.labels).split("\n")).toEqual(
|
|
expect.arrayContaining([
|
|
"org.opencontainers.image.revision=${{ inputs.release_sha }}",
|
|
"org.opencontainers.image.version=${{ needs.resolve.outputs.version }}",
|
|
"org.opencontainers.image.created=${{ needs.resolve.outputs.built_at }}",
|
|
]),
|
|
);
|
|
}
|
|
const browser = steps.find((step) => step.id === "build-browser");
|
|
expect(browser?.if).toBe("${{ needs.resolve.outputs.include_browser == 'true' }}");
|
|
expect(String(browser?.with?.["build-args"]).split("\n")).toContain(
|
|
"OPENCLAW_INSTALL_BROWSER=1",
|
|
);
|
|
for (const [variant, buildId] of [
|
|
["default", "build"],
|
|
["browser", "build-browser"],
|
|
]) {
|
|
const relay = steps.find((step) => step.name === `Relay ${variant} image limit warnings`);
|
|
expect(relay?.if).toBe(`\${{ always() && steps.${buildId}.outputs.metadata != '' }}`);
|
|
expect(relay?.run).toContain('["buildx.build.ref"]');
|
|
expect(relay?.["continue-on-error"]).toBe(true);
|
|
expect(relay?.run).toContain('docker buildx history logs --progress plain "$build_ref"');
|
|
expect(relay?.run).toContain("::notice title=Build limit warning relay skipped::");
|
|
expect(relay?.run).toContain("node workflow-source/scripts/relay-build-limit-warnings.mts");
|
|
}
|
|
const smoke = steps.findIndex((step) =>
|
|
step.run?.includes("docker-release-artifacts.mjs prepare"),
|
|
);
|
|
expect(smoke).toBeGreaterThan(-1);
|
|
expect(
|
|
steps.findIndex((step) => step.uses?.startsWith("actions/upload-artifact@")),
|
|
).toBeGreaterThan(smoke);
|
|
expect(prepare.jobs.seal.needs).toContain("build");
|
|
});
|
|
|
|
it("keeps preparation unable to publish and serializes the single approved writer", () => {
|
|
const prepare = parse(readFileSync(".github/workflows/docker-release-prepare.yml", "utf8"));
|
|
const publish = parse(readFileSync(".github/workflows/docker-release.yml", "utf8"));
|
|
expect(prepare.on.workflow_call.secrets).toBeUndefined();
|
|
expect(prepare.permissions).toEqual({ contents: "read" });
|
|
for (const job of Object.values(prepare.jobs) as {
|
|
permissions?: Record<string, string>;
|
|
environment?: string;
|
|
concurrency?: unknown;
|
|
steps?: { uses?: string; with?: Record<string, unknown> }[];
|
|
}[]) {
|
|
expect(
|
|
Object.values(job.permissions ?? {}).every((permission) => permission === "read"),
|
|
).toBe(true);
|
|
expect(job.environment).toBeUndefined();
|
|
expect(job.concurrency).toBeUndefined();
|
|
expect(job.steps?.some((step) => step.uses?.startsWith("docker/login-action@"))).toBe(false);
|
|
for (const step of job.steps ?? []) {
|
|
if (step.uses?.startsWith("docker/build-push-action@")) {
|
|
expect(step.with).toMatchObject({ push: false, sbom: true, provenance: "mode=max" });
|
|
}
|
|
}
|
|
}
|
|
expect(publish.jobs.prepare.uses).toBe("./.github/workflows/docker-release-prepare.yml");
|
|
expect(publish.jobs.prepare.secrets).toBeUndefined();
|
|
expect(publish.concurrency).toBeUndefined();
|
|
const approval = publish.jobs.approve;
|
|
const writer = publish.jobs.publish;
|
|
expect(approval, "approval must not hold the global publication lock").toBeDefined();
|
|
expect(approval.environment).toBe("docker-release");
|
|
expect(approval.permissions).toEqual({});
|
|
expect(approval.concurrency).toBeUndefined();
|
|
expect(JSON.stringify(approval)).not.toContain("secrets.");
|
|
expect(approval.needs).toEqual(["validate_release_identity", "prepare"]);
|
|
expect(writer.environment).toBeUndefined();
|
|
expect(writer.needs).toEqual(["validate_release_identity", "prepare", "approve"]);
|
|
for (const [identity, prepared, approved, cancelled, canApprove, canPublish] of [
|
|
["success", "success", "success", false, true, true],
|
|
["success", "skipped", "success", false, true, true],
|
|
["failure", "success", "success", false, false, false],
|
|
["success", "failure", "success", false, false, false],
|
|
["success", "success", "failure", false, true, false],
|
|
["success", "success", "skipped", false, true, false],
|
|
["success", "success", "cancelled", false, true, false],
|
|
["success", "success", "success", true, false, false],
|
|
] as const) {
|
|
const context = {
|
|
cancelled: () => cancelled,
|
|
needs: {
|
|
validate_release_identity: { result: identity },
|
|
prepare: { result: prepared },
|
|
approve: { result: approved },
|
|
},
|
|
};
|
|
const evaluate = (condition: string) => runInNewContext(condition.slice(3, -2), context);
|
|
expect(evaluate(approval.if)).toBe(canApprove);
|
|
expect(evaluate(writer.if)).toBe(canPublish);
|
|
}
|
|
expect(publish.jobs.publish.concurrency).toEqual({
|
|
group: "docker-release-publish",
|
|
"cancel-in-progress": false,
|
|
queue: "max",
|
|
});
|
|
expect(
|
|
Object.entries(publish.jobs)
|
|
.filter(
|
|
([, job]) =>
|
|
(job as { permissions?: { packages?: string } }).permissions?.packages === "write",
|
|
)
|
|
.map(([name]) => name),
|
|
).toEqual(["publish"]);
|
|
});
|
|
});
|