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
@@ -292,7 +292,9 @@ on pinned current `main` as the exact command and validation contract.
10. Dispatch the Docker-only `OpenClaw Release Publish` closeout. It must
reverify the exact npm selector and tarball, prepare a canonical
non-prerelease GitHub Release draft with `latest=false`, publish Docker,
and only then make the release public. Do not publish macOS, Windows,
persist and verify the exact-SHA Docker completion status, and only then
make the release public. On retry, skip Docker only for that durable status;
release draft/public state is never completion evidence. Do not publish macOS, Windows,
mobile, website, ClawHub, npm `latest`, or private dist-tag artifacts from
this path.
+4 -2
View File
@@ -101,8 +101,10 @@ the live tag, workflow, registry, provenance, and image state.
digest binding to the release SHA. Preserve output and workflow URLs.
5. **Docker:** verify exact default, slim, browser, and architecture images and
attestations in both registries. Only the three `extended-stable*` aliases may
resolve to those digests. Repair aliases through current-main `Docker Channel
Promotion` for the exact tag, without rebuilding.
resolve to those digests. Require the canonical `openclaw/docker-release/<VERSION>`
success status on the immutable release SHA and its `OpenClaw Release Publish`
run URL; release visibility is not Docker evidence. Repair aliases through
current-main `Docker Channel Promotion` for the exact tag, without rebuilding.
6. **Recovery:** never republish. Use the generated command only for the root
selector and approved credential-isolated tooling for others, then repeat
complete readback. Do not require ClawHub, native/mobile apps, website,
+38
View File
@@ -756,6 +756,7 @@ jobs:
permissions:
contents: read
packages: write
statuses: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -763,6 +764,14 @@ jobs:
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
fetch-depth: 1
- name: Checkout trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
path: .release-harness
fetch-depth: 1
persist-credentials: false
- *buildkit_prepull_step
- name: Set up Docker Builder
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
@@ -912,3 +921,32 @@ jobs:
--version "${VERSION}" \
--image "${GHCR_IMAGE}" \
--image "${DOCKERHUB_IMAGE}"
# This status is the durable resume boundary. Release visibility is not
# Docker evidence and must never suppress a missing publication.
- name: Record durable extended-stable Docker completion
if: ${{ needs.resolve_release_policy.outputs.channel == 'extended-stable' }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SHA: ${{ inputs.release_sha }}
VERSION: ${{ needs.resolve_release_policy.outputs.version }}
run: |
set -euo pipefail
payload="$(node .release-harness/scripts/docker-channel-promote.mjs \
--status-payload \
--version "${VERSION}" \
--repository "${GITHUB_REPOSITORY}" \
--source-sha "${RELEASE_SHA}" \
--run-id "${GITHUB_RUN_ID}")"
for attempt in 1 2 3; do
if printf '%s' "${payload}" | gh api --method POST \
"repos/${GITHUB_REPOSITORY}/statuses/${RELEASE_SHA}" \
--input - >/dev/null; then
exit 0
fi
if [[ "${attempt}" != "3" ]]; then
sleep $((attempt * 5))
fi
done
echo "Failed to persist durable Docker completion after verified publication." >&2
exit 1
+133 -12
View File
@@ -103,12 +103,14 @@ jobs:
timeout-minutes: 20
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
expected_validation_branch: ${{ steps.inputs.outputs.expected_validation_branch }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
preflight_tarball_sha256: ${{ steps.manifest.outputs.tarball_sha256 }}
full_release_validation_run_attempt: ${{ steps.full_run.outputs.attempt }}
windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}
steps:
- name: Validate inputs
id: inputs
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
@@ -247,6 +249,16 @@ jobs:
exit 1
;;
esac
expected_validation_branch="${WORKFLOW_REF#refs/heads/}"
if [[ "${PUBLISH_DOCKER_ONLY}" == "true" ]]; then
if [[ ! "${RELEASE_TAG}" =~ ^v([0-9]{4})\.([1-9][0-9]*)\.([1-9][0-9]*)$ ]] || \
(( 10#${BASH_REMATCH[3]:-0} < 33 )); then
echo "Extended-stable Docker closeout requires a final .33+ release tag." >&2
exit 1
fi
expected_validation_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33"
fi
echo "expected_validation_branch=${expected_validation_branch}" >> "$GITHUB_OUTPUT"
- name: Validate stable Windows source release
id: windows_source
@@ -486,7 +498,7 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
EXPECTED_SHA: ${{ steps.ref.outputs.sha }}
EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
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: ${{ runner.temp }}/release-validation-tooling/validate-full-release-validation-evidence.mjs
@@ -537,6 +549,8 @@ jobs:
- name: Validate release tag is reachable from a trusted release branch
env:
EXPECTED_VALIDATION_BRANCH: ${{ steps.inputs.outputs.expected_validation_branch }}
PUBLISH_DOCKER_ONLY: ${{ inputs.publish_docker_only && 'true' || 'false' }}
RELEASE_TAG: ${{ inputs.tag }}
WORKFLOW_REF_NAME: ${{ github.ref_name }}
run: |
@@ -545,6 +559,15 @@ jobs:
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*' \
'+refs/heads/extended-stable/*:refs/remotes/origin/extended-stable/*'
if [[ "${PUBLISH_DOCKER_ONLY}" == "true" ]]; then
expected_ref="refs/remotes/origin/${EXPECTED_VALIDATION_BRANCH}"
if git show-ref --verify --quiet "${expected_ref}" && \
git merge-base --is-ancestor HEAD "${expected_ref}"; then
exit 0
fi
echo "Extended-stable tag ${RELEASE_TAG} must be reachable from ${EXPECTED_VALIDATION_BRANCH}." >&2
exit 1
fi
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
@@ -2385,11 +2408,14 @@ jobs:
runs-on: ubuntu-latest
environment: npm-release
permissions:
actions: read
contents: write
statuses: read
outputs:
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 }}
steps:
- name: Verify exact npm and selector readback matches preflight bytes
env:
@@ -2550,7 +2576,7 @@ jobs:
- Scope: \`openclaw@${release_version}\`, official npm plugins, and Gateway container images only.
- Channel: npm and container \`extended-stable\`; regular \`latest\` is unchanged.
- Container publication: this draft is made public only after Docker publication succeeds.
- Container publication: this closeout requires durable Docker completion on the immutable release SHA.
- Excluded: ClawHub, macOS, Windows, mobile, website, and private dist-tags.
EOF
node .release-harness/scripts/render-github-release-notes.mjs \
@@ -2560,7 +2586,7 @@ jobs:
--verification-file "${verification_file}" \
--output "${notes_file}"
release_already_public=false
release_is_public=false
release_id="$(resolve_release_id)"
if [[ -n "${release_id}" ]]; then
verify_release_resource "${release_id}"
@@ -2591,8 +2617,8 @@ jobs:
elif [[ "${release_draft}" == "false" ]]; then
verify_release_resource "${release_id}" false true
wait_until_not_latest
release_already_public=true
echo "Existing canonical public release is already complete; Docker will not be rebuilt."
release_is_public=true
echo "Existing canonical public release found; Docker completion will be checked independently."
else
echo "GitHub release ${release_id} has an invalid draft state." >&2
exit 1
@@ -2600,11 +2626,39 @@ jobs:
release_body_sha256="$(
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" > "${docker_status_file}"
docker_status_run_id="$(node .release-harness/scripts/docker-channel-promote.mjs \
--find-status-file "${docker_status_file}" \
--version "${release_version}" \
--repository "${GITHUB_REPOSITORY}" \
--source-sha "${TARGET_SHA}")"
docker_already_published=false
if [[ -n "${docker_status_run_id}" ]]; then
docker_run_file="${RUNNER_TEMP}/extended-stable-docker-run.json"
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" > "${docker_run_file}"
jq -e --arg repository "${GITHUB_REPOSITORY}" \
--arg run_id "${docker_status_run_id}" '
(.id | tostring) == $run_id and
.name == "OpenClaw Release Publish" and
(.path | split("@")[0]) == ".github/workflows/openclaw-release-publish.yml" and
.event == "workflow_dispatch" and
.status == "completed" and
.repository.full_name == $repository
' "${docker_run_file}" >/dev/null || {
echo "Docker completion status does not reference a completed OpenClaw Release Publish run." >&2
exit 1
}
docker_already_published=true
fi
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
echo "release_body_sha256=${release_body_sha256}" >> "$GITHUB_OUTPUT"
echo "release_already_public=${release_already_public}" >> "$GITHUB_OUTPUT"
if [[ "${release_already_public}" == "true" ]]; then
echo "- Extended-stable GitHub release: already public; Docker rebuild skipped" >> "$GITHUB_STEP_SUMMARY"
echo "docker_already_published=${docker_already_published}" >> "$GITHUB_OUTPUT"
echo "docker_status_run_id=${docker_status_run_id}" >> "$GITHUB_OUTPUT"
if [[ "${docker_already_published}" == "true" ]]; then
echo "- Docker completion: durable status from run ${docker_status_run_id}; rebuild skipped" >> "$GITHUB_STEP_SUMMARY"
elif [[ "${release_is_public}" == "true" ]]; then
echo "- Extended-stable GitHub release: public without Docker completion; Docker publication required" >> "$GITHUB_STEP_SUMMARY"
else
echo "- Extended-stable GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
fi
@@ -2612,7 +2666,7 @@ jobs:
publish_docker:
name: Publish Docker images
needs: [resolve_release_target, publish, prepare_extended_stable_release]
if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.prepare_extended_stable_release.outputs.release_already_public != 'true')) }}
if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.prepare_extended_stable_release.outputs.docker_already_published != 'true')) }}
uses: ./.github/workflows/docker-release.yml
with:
tag: ${{ inputs.tag }}
@@ -2623,6 +2677,7 @@ jobs:
permissions:
contents: read
packages: write
statuses: write
finalize_github_release:
name: Finalize GitHub release
@@ -2651,10 +2706,76 @@ jobs:
exit 1
fi
verify_extended_stable_docker_completion:
name: Verify durable extended-stable Docker completion
needs: [resolve_release_target, prepare_extended_stable_release, publish_docker]
if: ${{ always() && inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && (needs.publish_docker.result == 'success' || (needs.publish_docker.result == 'skipped' && needs.prepare_extended_stable_release.outputs.docker_already_published == 'true')) }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
statuses: read
steps:
- name: Checkout trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
persist-credentials: false
- name: Verify durable Docker completion status
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
run: |
set -euo pipefail
release_version="${RELEASE_TAG#v}"
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" > "${status_file}"
docker_status_run_id="$(node scripts/docker-channel-promote.mjs \
--find-status-file "${status_file}" \
--version "${release_version}" \
--repository "${GITHUB_REPOSITORY}" \
--source-sha "${TARGET_SHA}")"
if [[ -n "${docker_status_run_id}" ]]; then
break
fi
if [[ "${attempt}" == "12" ]]; then
echo "Docker publication completed without a durable completion status." >&2
exit 1
fi
sleep 5
done
docker_run_file="${RUNNER_TEMP}/extended-stable-docker-run.json"
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" > "${docker_run_file}"
jq -e --arg current_run_id "${GITHUB_RUN_ID}" \
--arg repository "${GITHUB_REPOSITORY}" \
--arg run_id "${docker_status_run_id}" '
(.id | tostring) == $run_id and
.name == "OpenClaw Release Publish" and
(.path | split("@")[0]) == ".github/workflows/openclaw-release-publish.yml" and
.event == "workflow_dispatch" and
(.status == "completed" or ($run_id == $current_run_id and .status == "in_progress")) and
.repository.full_name == $repository
' "${docker_run_file}" >/dev/null || {
echo "Docker completion status does not reference the expected release workflow run." >&2
exit 1
}
echo "- Durable Docker completion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" >> "$GITHUB_STEP_SUMMARY"
finalize_extended_stable_github_release:
name: Finalize extended-stable GitHub release
needs: [resolve_release_target, prepare_extended_stable_release, publish_docker]
if: ${{ always() && inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.publish_docker.result == 'success' }}
needs:
[
resolve_release_target,
prepare_extended_stable_release,
verify_extended_stable_docker_completion,
]
if: ${{ always() && inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.verify_extended_stable_docker_completion.result == 'success' }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
+6 -3
View File
@@ -317,9 +317,12 @@ preflight and Full Release Validation evidence as a core publish.
Extended-stable uses this workflow only after npm publication, with
`publish_openclaw_npm=false` and `publish_docker_only=true`. That closeout
rechecks immutable npm evidence, publishes Docker, and finalizes the notes-only
non-Latest GitHub Release without entering plugin, ClawHub, or native-app
publication.
rechecks immutable npm evidence and the exact canonical validation branch,
publishes Docker, records durable Docker completion on the immutable release
SHA, and finalizes the notes-only non-Latest GitHub Release without entering
plugin, ClawHub, or native-app publication. A public release page never skips
Docker by itself; only the verified completion status can make a retry resume
after Docker.
```bash
gh workflow run openclaw-release-publish.yml \
+5 -1
View File
@@ -204,7 +204,11 @@ saved npm preflight artifact, exact `Full Release Validation` evidence, exact np
version and `extended-stable` selector, and published tarball digest. It then
creates or resumes a canonical non-prerelease GitHub Release draft with
`latest=false`, calls the reusable `Docker Release` workflow, and makes the
draft public only after Docker succeeds. A tag push never publishes Docker
draft public only after Docker succeeds. After image, attestation, platform,
and channel-alias verification, Docker Release records a success status on the
immutable release SHA. Retries skip a rebuild only when that exact durable
status and its release-workflow run are verified; a public GitHub Release page
alone is never Docker-completion evidence. A tag push never publishes Docker
images or a release page by itself:
```bash
@@ -52,6 +52,11 @@ gh workflow run full-release-validation.yml \
-f release_profile=stable
```
The Docker-only closeout derives `extended-stable/YYYY.M.33` from the final
`.33+` tag, requires the tag commit to be reachable from that exact branch, and
passes that branch to the validation-evidence verifier. It does not substitute
the closeout workflow's `main` ref for the candidate branch.
The helper's temporary `release-ci/*` ref is valid only when every child uses
the pinned trusted workflow SHA and the v3 manifest binds the canonical branch,
exact release commit, run ID, and attempt. Reject narrow runs, stale attempts,
+128
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import process from "node:process";
import { parseArgs } from "node:util";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
@@ -18,6 +19,104 @@ const VARIANTS = Object.freeze([
{ aliasKey: "slim", suffix: "-slim" },
{ aliasKey: "browser", suffix: "-browser" },
]);
const DOCKER_PUBLICATION_STATUS_DESCRIPTION =
"Verified GHCR + Docker Hub images, attestations, platforms, and channel aliases.";
const DOCKER_PUBLICATION_STATUS_PREFIX = "openclaw/docker-release";
/** @typedef {{ repository: string; sourceSha: string; version: string }} DockerPublicationIdentity */
/** @typedef {DockerPublicationIdentity & { runId: number | string }} DockerPublicationStatusParams */
/** @typedef {{ context: string; description: string; state: "success"; target_url: string }} DockerPublicationStatus */
/**
* @typedef {object} GitHubCommitStatus
* @property {unknown} [context]
* @property {{ login?: unknown }} [creator]
* @property {unknown} [description]
* @property {unknown} [state]
* @property {unknown} [target_url]
*/
/** @typedef {{ sha?: unknown; statuses?: GitHubCommitStatus[] }} GitHubCombinedStatus */
/** @param {DockerPublicationIdentity} params */
function requireExtendedStableStatusIdentity({ version, repository, sourceSha }) {
const policy = resolveDockerReleasePolicy(version);
if (policy.channel !== "extended-stable") {
throw new Error(`Docker completion status is only valid for extended-stable; got ${version}.`);
}
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
throw new Error(`Invalid GitHub repository identity ${JSON.stringify(repository)}.`);
}
if (!/^[a-f0-9]{40}$/u.test(sourceSha)) {
throw new Error("Docker completion status requires a full lowercase source SHA.");
}
return policy;
}
/**
* Build the durable commit status written only after Docker verification completes.
*
* @param {DockerPublicationStatusParams} params
* @returns {DockerPublicationStatus}
*/
export function createDockerPublicationStatus({ version, repository, sourceSha, runId }) {
const policy = requireExtendedStableStatusIdentity({ version, repository, sourceSha });
if (!/^[1-9][0-9]*$/u.test(String(runId))) {
throw new Error("Docker completion status requires a positive workflow run ID.");
}
return {
context: `${DOCKER_PUBLICATION_STATUS_PREFIX}/${policy.version}`,
description: DOCKER_PUBLICATION_STATUS_DESCRIPTION,
state: "success",
target_url: `https://github.com/${repository}/actions/runs/${runId}`,
};
}
/**
* Resolve a canonical Docker completion status from GitHub's combined-status response.
*
* @param {DockerPublicationIdentity & { combinedStatus: unknown }} params
* @returns {{ runId: string; targetUrl: string } | null}
*/
export function findDockerPublicationStatus({ combinedStatus, 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.");
}
const matches = response.statuses.filter(
(status) => String(status?.context ?? "").toLowerCase() === expected.context.toLowerCase(),
);
if (matches.length === 0) {
return null;
}
if (matches.length !== 1) {
throw new Error(
`GitHub returned duplicate Docker completion statuses for ${expected.context}.`,
);
}
const status = matches[0];
const targetMatch = new RegExp(
`^https://github\\.com/${repository.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}/actions/runs/([1-9][0-9]*)$`,
"u",
).exec(String(status.target_url ?? ""));
if (
status.state !== expected.state ||
status.context !== expected.context ||
status.description !== expected.description ||
status.creator?.login !== "github-actions[bot]" ||
!targetMatch
) {
throw new Error(`Docker completion status ${expected.context} is not canonical.`);
}
return {
runId: targetMatch[1],
targetUrl: String(status.target_url),
};
}
/** @typedef {{ images: string[]; version: string }} DockerPromotionParams */
/**
@@ -277,6 +376,8 @@ export function promoteDockerChannel({ version, images }, options = {}) {
function printHelp() {
console.log(
"Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--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",
);
}
@@ -285,8 +386,13 @@ function main() {
args: process.argv.slice(2),
options: {
"allow-rollback": { type: "boolean" },
"find-status-file": { type: "string" },
help: { type: "boolean", short: "h" },
image: { type: "string", multiple: true },
repository: { type: "string" },
"run-id": { type: "string" },
"source-sha": { type: "string" },
"status-payload": { type: "boolean" },
version: { type: "string" },
},
strict: true,
@@ -299,6 +405,28 @@ function main() {
if (!version) {
throw new Error("--version is required.");
}
if (values["status-payload"]) {
const payload = createDockerPublicationStatus({
version,
repository: values.repository ?? "",
sourceSha: values["source-sha"] ?? "",
runId: values["run-id"] ?? "",
});
process.stdout.write(`${JSON.stringify(payload)}\n`);
return;
}
if (values["find-status-file"]) {
const match = findDockerPublicationStatus({
combinedStatus: JSON.parse(readFileSync(values["find-status-file"], "utf8")),
version,
repository: values.repository ?? "",
sourceSha: values["source-sha"] ?? "",
});
if (match) {
process.stdout.write(`${match.runId}\n`);
}
return;
}
const images = (values.image ?? []).map((image) => image.trim());
if (images.length === 0 || images.some((image) => image.length === 0)) {
throw new Error("At least one non-empty --image is required.");
+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(