From 1d29bf7cb6be03000c0d8ea3a3fc40e73cf5c74d Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Mon, 10 Aug 2026 17:41:59 +0800 Subject: [PATCH] fix(release): read canonical Docker status history --- .../workflows/openclaw-release-publish.yml | 8 +- scripts/docker-channel-promote.mjs | 42 +++++----- test/scripts/docker-channel-promote.test.ts | 84 ++++++++++++++++--- test/scripts/release-no-push-workflow.test.ts | 10 ++- 4 files changed, 102 insertions(+), 42 deletions(-) diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index a6ffc18bc320..3db028b1cb22 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -2744,9 +2744,9 @@ jobs: sha256sum "${notes_file}" | awk '{print $1}' )" docker_status_file="${RUNNER_TEMP}/extended-stable-docker-status.json" - gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/status?per_page=100" > "${docker_status_file}" + gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/statuses?per_page=100" > "${docker_status_file}" docker_status_run_id="$(node .release-harness/scripts/docker-channel-promote.mjs \ - --find-status-file "${docker_status_file}" \ + --find-statuses-file "${docker_status_file}" \ --version "${release_version}" \ --repository "${GITHUB_REPOSITORY}" \ --source-sha "${TARGET_SHA}")" @@ -2865,9 +2865,9 @@ jobs: status_file="${RUNNER_TEMP}/extended-stable-docker-status.json" docker_status_run_id="" for attempt in $(seq 1 12); do - gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/status?per_page=100" > "${status_file}" + gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/statuses?per_page=100" > "${status_file}" docker_status_run_id="$(node scripts/docker-channel-promote.mjs \ - --find-status-file "${status_file}" \ + --find-statuses-file "${status_file}" \ --version "${release_version}" \ --repository "${GITHUB_REPOSITORY}" \ --source-sha "${TARGET_SHA}")" diff --git a/scripts/docker-channel-promote.mjs b/scripts/docker-channel-promote.mjs index 60a9e9bfaab4..f6124cba2411 100644 --- a/scripts/docker-channel-promote.mjs +++ b/scripts/docker-channel-promote.mjs @@ -33,8 +33,8 @@ const DOCKER_PUBLICATION_STATUS_PREFIX = "openclaw/docker-release"; * @property {unknown} [description] * @property {unknown} [state] * @property {unknown} [target_url] + * @property {unknown} [url] */ -/** @typedef {{ sha?: unknown; statuses?: GitHubCommitStatus[] }} GitHubCombinedStatus */ /** @param {DockerPublicationIdentity} params */ function requireExtendedStableStatusIdentity({ version, repository, sourceSha }) { @@ -71,36 +71,33 @@ export function createDockerPublicationStatus({ version, repository, sourceSha, } /** - * Resolve a canonical Docker completion status from GitHub's combined-status response. + * Resolve the newest canonical Docker completion status from GitHub's + * reverse-chronological commit-status history. * - * @param {DockerPublicationIdentity & { combinedStatus: unknown }} params + * @param {DockerPublicationIdentity & { statuses: unknown }} params * @returns {{ runId: string; targetUrl: string } | null} */ -export function findDockerPublicationStatus({ combinedStatus, version, repository, sourceSha }) { +export function findDockerPublicationStatus({ statuses, version, repository, sourceSha }) { const expected = createDockerPublicationStatus({ version, repository, sourceSha, runId: 1, }); - const response = /** @type {GitHubCombinedStatus} */ (combinedStatus); - if (response?.sha !== sourceSha || !Array.isArray(response?.statuses)) { - throw new Error("GitHub combined status is not bound to the expected release SHA."); + if (!Array.isArray(statuses)) { + throw new Error("GitHub commit status history must be an array."); } - const matches = response.statuses.filter( - (status) => - typeof status?.context === "string" && - status.context.toLowerCase() === expected.context.toLowerCase(), + // GitHub returns newest first. Validate that matching record directly so a + // malformed retry cannot be bypassed by an older canonical success. + const status = /** @type {GitHubCommitStatus[]} */ (statuses).find( + (candidate) => + typeof candidate?.context === "string" && + candidate.context.toLowerCase() === expected.context.toLowerCase(), ); - if (matches.length === 0) { + if (!status) { return null; } - if (matches.length !== 1) { - throw new Error( - `GitHub returned duplicate Docker completion statuses for ${expected.context}.`, - ); - } - const status = matches[0]; + const expectedStatusUrl = `https://api.github.com/repos/${repository}/statuses/${sourceSha}`; const targetUrl = typeof status.target_url === "string" ? status.target_url : ""; const targetMatch = new RegExp( `^https://github\\.com/${repository.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}/actions/runs/([1-9][0-9]*)$`, @@ -111,6 +108,7 @@ export function findDockerPublicationStatus({ combinedStatus, version, repositor status.context !== expected.context || status.description !== expected.description || status.creator?.login !== "github-actions[bot]" || + status.url !== expectedStatusUrl || !targetMatch ) { throw new Error(`Docker completion status ${expected.context} is not canonical.`); @@ -387,7 +385,7 @@ function printHelp() { console.log( `Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--image-tag-suffix -rYYYYMMDD] [--allow-rollback] node scripts/docker-channel-promote.mjs --status-payload --version YYYY.M.P --repository OWNER/REPO --source-sha SHA --run-id ID - node scripts/docker-channel-promote.mjs --find-status-file FILE --version YYYY.M.P --repository OWNER/REPO --source-sha SHA`, + node scripts/docker-channel-promote.mjs --find-statuses-file FILE --version YYYY.M.P --repository OWNER/REPO --source-sha SHA`, ); } @@ -396,7 +394,7 @@ function main() { args: process.argv.slice(2), options: { "allow-rollback": { type: "boolean" }, - "find-status-file": { type: "string" }, + "find-statuses-file": { type: "string" }, help: { type: "boolean", short: "h" }, image: { type: "string", multiple: true }, "image-tag-suffix": { type: "string", default: "" }, @@ -426,9 +424,9 @@ function main() { process.stdout.write(`${JSON.stringify(payload)}\n`); return; } - if (values["find-status-file"]) { + if (values["find-statuses-file"]) { const match = findDockerPublicationStatus({ - combinedStatus: JSON.parse(readFileSync(values["find-status-file"], "utf8")), + statuses: JSON.parse(readFileSync(values["find-statuses-file"], "utf8")), version, repository: values.repository ?? "", sourceSha: values["source-sha"] ?? "", diff --git a/test/scripts/docker-channel-promote.test.ts b/test/scripts/docker-channel-promote.test.ts index 282befd900bd..3e715fc7ed95 100644 --- a/test/scripts/docker-channel-promote.test.ts +++ b/test/scripts/docker-channel-promote.test.ts @@ -106,7 +106,7 @@ const bashRunsWorkflowSteps = spawnSync("bash", ["-c", "type mapfile"], { encoding: "utf8" }).status === 0; describe("Docker channel promotion", () => { - it("binds durable extended-stable completion to the release SHA and workflow run", () => { + it("accepts the newest canonical status from reverse-chronological history", () => { const sourceSha = "a".repeat(40); const payload = createDockerPublicationStatus({ version: "2026.6.35", @@ -124,10 +124,20 @@ describe("Docker channel promotion", () => { }); expect( findDockerPublicationStatus({ - combinedStatus: { - sha: sourceSha, - statuses: [{ ...payload, creator: { login: "github-actions[bot]" } }], - }, + statuses: [ + { context: "unrelated/status", state: "success" }, + { + ...payload, + creator: { login: "github-actions[bot]" }, + url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`, + }, + { + ...payload, + creator: { login: "github-actions[bot]" }, + target_url: "https://github.com/openclaw/openclaw/actions/runs/11111", + url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`, + }, + ], version: "2026.6.35", repository: "openclaw/openclaw", sourceSha, @@ -135,11 +145,11 @@ describe("Docker channel promotion", () => { ).toEqual({ runId: "12345", targetUrl: payload.target_url }); }); - it("does not accept release visibility or malformed status as Docker completion", () => { + it("does not accept absent, malformed, or combined-response status evidence", () => { const sourceSha = "a".repeat(40); expect( findDockerPublicationStatus({ - combinedStatus: { sha: sourceSha, statuses: [] }, + statuses: [], version: "2026.6.35", repository: "openclaw/openclaw", sourceSha, @@ -152,24 +162,74 @@ describe("Docker channel promotion", () => { sourceSha, runId: "12345", }); + const canonical = { + ...payload, + creator: { login: "github-actions[bot]" }, + url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`, + }; for (const status of [ - { ...payload, state: "pending", creator: { login: "github-actions[bot]" } }, - { ...payload, creator: { login: "someone-else" } }, + { ...canonical, state: "pending" }, + { ...canonical, creator: { login: "someone-else" } }, { - ...payload, + ...canonical, description: "images probably published", - creator: { login: "github-actions[bot]" }, + }, + { + ...canonical, + url: `https://api.github.com/repos/openclaw/openclaw/statuses/${"b".repeat(40)}`, }, ]) { expect(() => findDockerPublicationStatus({ - combinedStatus: { sha: sourceSha, statuses: [status] }, + statuses: [status], version: "2026.6.35", repository: "openclaw/openclaw", sourceSha, }), ).toThrow("is not canonical"); } + + expect(() => + findDockerPublicationStatus({ + statuses: { sha: sourceSha, statuses: [] }, + version: "2026.6.35", + repository: "openclaw/openclaw", + sourceSha, + }), + ).toThrow("status history must be an array"); + }); + + it("fails closed on a malformed newest matching status", () => { + const sourceSha = "a".repeat(40); + const payload = createDockerPublicationStatus({ + version: "2026.6.35", + repository: "openclaw/openclaw", + sourceSha, + runId: "12345", + }); + const canonical = { + ...payload, + creator: { login: "github-actions[bot]" }, + url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`, + }; + + expect(() => + findDockerPublicationStatus({ + statuses: [{ ...canonical, description: "unverified images" }, canonical], + version: "2026.6.35", + repository: "openclaw/openclaw", + sourceSha, + }), + ).toThrow("is not canonical"); + + expect(() => + findDockerPublicationStatus({ + statuses: [{ ...canonical, context: canonical.context.toUpperCase() }, canonical], + version: "2026.6.35", + repository: "openclaw/openclaw", + sourceSha, + }), + ).toThrow("is not canonical"); }); it("plans every extended-stable image variant in both registries", () => { diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 2d517c19a25d..5c4996a21730 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -1175,8 +1175,9 @@ describe("release validation no-push transport", () => { expect(createDraft.run).toContain('verify_release_resource "${release_id}" false true'); expect(createDraft.run).toContain("wait_until_not_latest"); expect(createDraft.run).toContain("Docker completion will be checked independently"); - expect(createDraft.run).toContain("commits/${TARGET_SHA}/status?per_page=100"); - expect(createDraft.run).toContain("--find-status-file"); + expect(createDraft.run).toContain("commits/${TARGET_SHA}/statuses?per_page=100"); + expect(createDraft.run).toContain("--find-statuses-file"); + expect(createDraft.run).not.toContain("commits/${TARGET_SHA}/status?per_page=100"); expect(createDraft.run).toContain("docker_already_published=true"); expect(createDraft.run).toContain("public without Docker completion"); expect(createDraft.run).toContain("wait_for_release_id"); @@ -1195,8 +1196,9 @@ describe("release validation no-push transport", () => { verifyDockerCompletion, "Verify durable Docker completion status", ).run; - expect(verifyDockerCompletionRun).toContain("commits/${TARGET_SHA}/status?per_page=100"); - expect(verifyDockerCompletionRun).toContain("--find-status-file"); + expect(verifyDockerCompletionRun).toContain("commits/${TARGET_SHA}/statuses?per_page=100"); + expect(verifyDockerCompletionRun).toContain("--find-statuses-file"); + expect(verifyDockerCompletionRun).not.toContain("commits/${TARGET_SHA}/status?per_page=100"); expect(finalizeRelease.needs).toEqual([ "resolve_release_target", "prepare_extended_stable_release",