fix(release): read canonical Docker status history

This commit is contained in:
Dallin Romney
2026-08-10 17:41:59 +08:00
parent 4b183956e8
commit 1d29bf7cb6
4 changed files with 102 additions and 42 deletions
@@ -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}")"
+20 -22
View File
@@ -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"] ?? "",
+72 -12
View File
@@ -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", () => {
@@ -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",