mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(release): read canonical Docker status history
This commit is contained in:
@@ -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}")"
|
||||
|
||||
@@ -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"] ?? "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user