ci: persist extended-stable Docker completion

This commit is contained in:
Dallin Romney
2026-08-09 14:50:02 +08:00
parent 883eb91053
commit e323d862cb
12 changed files with 501 additions and 31 deletions
+86 -2
View File
@@ -3,6 +3,8 @@ import { describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
import {
createDockerChannelPromotionPlan,
createDockerPublicationStatus,
findDockerPublicationStatus,
promoteDockerChannel,
} from "../../scripts/docker-channel-promote.mjs";
@@ -80,6 +82,72 @@ function requireJob(workflow: Workflow, name: string): WorkflowJob {
}
describe("Docker channel promotion", () => {
it("binds durable extended-stable completion to the release SHA and workflow run", () => {
const sourceSha = "a".repeat(40);
const payload = createDockerPublicationStatus({
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
runId: "12345",
});
expect(payload).toEqual({
context: "openclaw/docker-release/2026.6.35",
description:
"Verified GHCR + Docker Hub images, attestations, platforms, and channel aliases.",
state: "success",
target_url: "https://github.com/openclaw/openclaw/actions/runs/12345",
});
expect(
findDockerPublicationStatus({
combinedStatus: {
sha: sourceSha,
statuses: [{ ...payload, creator: { login: "github-actions[bot]" } }],
},
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toEqual({ runId: "12345", targetUrl: payload.target_url });
});
it("does not accept release visibility or malformed status as Docker completion", () => {
const sourceSha = "a".repeat(40);
expect(
findDockerPublicationStatus({
combinedStatus: { sha: sourceSha, statuses: [] },
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toBeNull();
const payload = createDockerPublicationStatus({
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
runId: "12345",
});
for (const status of [
{ ...payload, state: "pending", creator: { login: "github-actions[bot]" } },
{ ...payload, creator: { login: "someone-else" } },
{
...payload,
description: "images probably published",
creator: { login: "github-actions[bot]" },
},
]) {
expect(() =>
findDockerPublicationStatus({
combinedStatus: { sha: sourceSha, statuses: [status] },
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toThrow("is not canonical");
}
});
it("plans every extended-stable image variant in both registries", () => {
expect(createDockerChannelPromotionPlan({ version: "2026.6.33", images })).toEqual({
channel: "extended-stable",
@@ -379,7 +447,11 @@ describe("Docker channel promotion", () => {
"cancel-in-progress": false,
queue: "max",
});
expect(verifyAttestations.permissions).toEqual({ contents: "read", packages: "write" });
expect(verifyAttestations.permissions).toEqual({
contents: "read",
packages: "write",
statuses: "write",
});
const manifestTagStep = createManifest.steps?.find(
(step) => step.name === "Resolve manifest tags",
@@ -406,9 +478,21 @@ describe("Docker channel promotion", () => {
"node scripts/docker-channel-promote.mjs",
);
expect(releaseSteps[releasePromotionIndex]?.run).not.toContain("--allow-rollback");
const completionIndex = releaseSteps.findIndex(
(step) => step.name === "Record durable extended-stable Docker completion",
);
expect(completionIndex).toBeGreaterThan(releasePromotionIndex);
expect(releaseSteps[completionIndex]?.if).toBe(
"${{ needs.resolve_release_policy.outputs.channel == 'extended-stable' }}",
);
expect(releaseSteps[completionIndex]?.run).toContain("--status-payload");
expect(releaseSteps[completionIndex]?.run).toContain("statuses/${RELEASE_SHA}");
expect(
Object.values(releaseWorkflow.jobs ?? {}).flatMap((job) =>
(job.steps ?? []).filter((step) => step.run?.includes("docker-channel-promote.mjs")),
(job.steps ?? []).filter(
(step) =>
step.run?.includes("docker-channel-promote.mjs") && step.run.includes("--image"),
),
),
).toHaveLength(1);
@@ -632,11 +632,13 @@ function runReleasePublishInputValidation(overrides: Record<string, string>) {
if (!script) {
throw new Error("Expected release publish input validation script");
}
const githubOutput = resolve(tempDirs.make("release-publish-inputs-"), "github-output");
return spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: {
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: "1",
FULL_RELEASE_VALIDATION_RUN_ID: "222",
GITHUB_OUTPUT: githubOutput,
OPENCLAW_NPM_RESUME_RUN_ID: "",
PATH: process.env.PATH,
PLUGINS: "",
@@ -5284,6 +5286,7 @@ describe("package artifact reuse", () => {
);
expect(trustedTooling.env?.WORKFLOW_SHA).toBe("${{ github.sha }}");
expect(validateManifest.env).toMatchObject({
EXPECTED_WORKFLOW_BRANCH: "${{ steps.inputs.outputs.expected_validation_branch }}",
RUN_JSON_FILE: "${{ runner.temp }}/full-release-validation-run.json",
TRUSTED_MAIN_REF: "refs/remotes/origin/main",
VALIDATOR_FILE:
@@ -5352,6 +5355,14 @@ describe("package artifact reuse", () => {
extendedPrepareJob,
"Create or resume the canonical draft release",
);
const extendedDockerCompletionJob = workflowJob(
RELEASE_PUBLISH_WORKFLOW,
"verify_extended_stable_docker_completion",
);
const extendedDockerCompletion = workflowStep(
extendedDockerCompletionJob,
"Verify durable Docker completion status",
);
const extendedFinalizeJob = workflowJob(
RELEASE_PUBLISH_WORKFLOW,
"finalize_extended_stable_github_release",
@@ -5365,15 +5376,26 @@ describe("package artifact reuse", () => {
expect(extendedPrepare.run).toContain("verifyGithubReleaseNotes");
expect(extendedPrepare.run).toContain("body !== expectedBody");
expect(extendedPrepare.run).toContain("release.assets.length !== 0");
expect(extendedPrepare.run).toContain("release_already_public=true");
expect(extendedPrepare.run).toContain("--find-status-file");
expect(extendedPrepare.run).toContain("docker_already_published=true");
expect(extendedPrepare.run).toContain("public without Docker completion");
expect(extendedPrepare.run).toContain("--draft");
expect(extendedPrepare.run).toContain("--latest=false");
expect(extendedFinalizeJob.needs).toEqual([
expect(extendedDockerCompletionJob.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"publish_docker",
]);
expect(extendedFinalizeJob.if).toContain("needs.publish_docker.result == 'success'");
expect(extendedDockerCompletion.run).toContain("--find-status-file");
expect(extendedDockerCompletion.run).toContain("Docker publication completed without");
expect(extendedFinalizeJob.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"verify_extended_stable_docker_completion",
]);
expect(extendedFinalizeJob.if).toContain(
"needs.verify_extended_stable_docker_completion.result == 'success'",
);
expect(extendedFinalize.run).toContain("-f make_latest=false");
expect(extendedFinalize.run).toContain("EXPECTED_BODY_SHA256");
expect(extendedFinalize.run).toContain("release.assets.length !== 0");
+49 -7
View File
@@ -1013,6 +1013,13 @@ describe("release validation no-push transport", () => {
const releasePublishPath = ".github/workflows/openclaw-release-publish.yml";
const releasePublish = readWorkflow(releasePublishPath);
const dockerCall = job(releasePublish, "publish_docker");
const resolveTarget = job(releasePublish, "resolve_release_target");
const validateInputs = step(resolveTarget, "Validate inputs");
const validateEvidence = step(resolveTarget, "Validate full release validation manifest");
const validateReleaseBranch = step(
resolveTarget,
"Validate release tag is reachable from a trusted release branch",
);
expect(dockerRelease.on?.push).toBeUndefined();
expect(dockerRelease.on?.workflow_dispatch).toBeUndefined();
@@ -1035,6 +1042,20 @@ describe("release validation no-push transport", () => {
.toSorted();
expect(callers).toEqual(["openclaw-release-publish.yml"]);
expect(validateInputs.id).toBe("inputs");
expect(validateInputs.run).toContain(
'expected_validation_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33"',
);
expect(validateEvidence.env?.EXPECTED_WORKFLOW_BRANCH).toBe(
"${{ steps.inputs.outputs.expected_validation_branch }}",
);
expect(validateReleaseBranch.run).toContain(
'expected_ref="refs/remotes/origin/${EXPECTED_VALIDATION_BRANCH}"',
);
expect(validateReleaseBranch.run).toContain(
"must be reachable from ${EXPECTED_VALIDATION_BRANCH}",
);
expect(dockerCall.needs).toEqual([
"resolve_release_target",
"publish",
@@ -1043,7 +1064,7 @@ describe("release validation no-push transport", () => {
expect(dockerCall.if).toContain("needs.publish.result == 'success'");
expect(dockerCall.if).toContain("needs.prepare_extended_stable_release.result == 'success'");
expect(dockerCall.if).toContain(
"needs.prepare_extended_stable_release.outputs.release_already_public != 'true'",
"needs.prepare_extended_stable_release.outputs.docker_already_published != 'true'",
);
expect(dockerCall.with).toEqual({
tag: "${{ inputs.tag }}",
@@ -1085,11 +1106,16 @@ describe("release validation no-push transport", () => {
expect(prepareRelease.needs).toEqual(["resolve_release_target"]);
expect(prepareRelease.if).toBe("${{ inputs.publish_docker_only }}");
expect(prepareRelease.environment).toBe("npm-release");
expect(prepareRelease.permissions).toEqual({ contents: "write" });
expect(prepareRelease.permissions).toEqual({
actions: "read",
contents: "write",
statuses: "read",
});
expect(prepareRelease.outputs).toEqual({
docker_already_published: "${{ steps.release.outputs.docker_already_published }}",
docker_status_run_id: "${{ steps.release.outputs.docker_status_run_id }}",
release_id: "${{ steps.release.outputs.release_id }}",
release_body_sha256: "${{ steps.release.outputs.release_body_sha256 }}",
release_already_public: "${{ steps.release.outputs.release_already_public }}",
});
expect(verifyNpm.run).toContain('npm view "openclaw@${version}" version');
expect(verifyNpm.run).toContain("Published npm tarball does not match");
@@ -1104,21 +1130,37 @@ describe("release validation no-push transport", () => {
expect(createDraft.run).toContain("release.assets.length !== 0");
expect(createDraft.run).toContain('verify_release_resource "${release_id}" false true');
expect(createDraft.run).toContain("wait_until_not_latest");
expect(createDraft.run).toContain("release_already_public=true");
expect(createDraft.run).toContain("Docker will not be rebuilt");
expect(createDraft.run).toContain("Docker completion will be checked independently");
expect(createDraft.run).toContain("--find-status-file");
expect(createDraft.run).toContain("docker_already_published=true");
expect(createDraft.run).toContain("public without Docker completion");
expect(createDraft.run).toContain("wait_for_release_id");
expect(createDraft.run).toContain('sha256sum "${notes_file}"');
expect(finalizeRelease.needs).toEqual([
const verifyDockerCompletion = job(releasePublish, "verify_extended_stable_docker_completion");
expect(verifyDockerCompletion.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"publish_docker",
]);
expect(verifyDockerCompletion.if).toContain(
"needs.prepare_extended_stable_release.outputs.docker_already_published == 'true'",
);
expect(step(verifyDockerCompletion, "Verify durable Docker completion status").run).toContain(
"--find-status-file",
);
expect(finalizeRelease.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"verify_extended_stable_docker_completion",
]);
expect(finalizeRelease.if).toContain("inputs.publish_docker_only");
expect(finalizeRelease.if).toContain(
"needs.prepare_extended_stable_release.result == 'success'",
);
expect(finalizeRelease.if).toContain("needs.publish_docker.result == 'success'");
expect(finalizeRelease.if).toContain(
"needs.verify_extended_stable_docker_completion.result == 'success'",
);
expect(finalizeRelease.environment).toBe("npm-release");
expect(finalizeRelease.permissions).toEqual({ contents: "write" });
expect(publishDraft.env).toMatchObject({
@@ -154,6 +154,25 @@ describe("full release validation evidence", () => {
}
});
it("accepts direct evidence from the derived canonical extended-stable branch", () => {
const branch = "extended-stable/2026.6.33";
const result = validateFullReleaseValidationEvidence({
run: releaseRun({ head_branch: branch }),
manifest: releaseManifest({
workflowRef: branch,
workflowFullRef: `refs/heads/${branch}`,
targetRef: "v2026.6.35",
}),
expectedRepository: "openclaw/openclaw",
expectedRunId: "123",
expectedTargetSha: targetSha,
expectedWorkflowBranch: branch,
isTrustedMainAncestor: () => false,
});
expect(result.source).toBe("direct");
});
it("rejects direct main evidence outside current main", () => {
expect(() =>
validate(