mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 06:33:09 -06:00
ci: persist extended-stable Docker completion
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user