fix(release): bind Docker writers to trusted tooling

This commit is contained in:
Vincent Koc
2026-08-20 21:15:32 -07:00
parent ac8018edcf
commit 4bad79f6ab
11 changed files with 329 additions and 16 deletions
@@ -14,6 +14,10 @@ env:
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_REGISTRY: docker.io
DOCKERHUB_IMAGE_NAME: openclaw/openclaw
RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "false"
RELEASE_TOOLING_FULL_REF: ${{ github.ref }}
RELEASE_TOOLING_REF: ${{ github.ref_name }}
RELEASE_TOOLING_SHA: ${{ github.workflow_sha }}
jobs:
resolve:
@@ -108,6 +112,7 @@ jobs:
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
packages: write
steps:
@@ -164,6 +169,7 @@ jobs:
- name: Promote and verify channel aliases
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.resolve.outputs.version }}
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
@@ -21,6 +21,7 @@ on:
- cron: "17 3 * * 1"
permissions:
actions: read
contents: read
env:
@@ -130,9 +131,14 @@ jobs:
tag: ${{ matrix.tag }}
release_sha: ${{ matrix.release_sha }}
image_tag_suffix: ${{ needs.plan.outputs.image_tag_suffix }}
trusted_workflow_allow_prevalidated_ref: false
trusted_workflow_full_ref: ${{ github.ref }}
trusted_workflow_ref: ${{ github.ref_name }}
trusted_workflow_sha: ${{ github.workflow_sha }}
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
permissions:
actions: read
contents: read
packages: write
+88 -9
View File
@@ -16,6 +16,32 @@ on:
required: false
default: ""
type: string
trusted_workflow_ref:
description: Trusted release tooling ref name
required: true
type: string
trusted_workflow_full_ref:
description: Trusted release tooling full ref
required: true
type: string
trusted_workflow_sha:
description: Trusted release tooling commit SHA
required: true
type: string
trusted_workflow_allow_prevalidated_ref:
description: Allow an exact live branch outside main as trusted release tooling
required: true
type: boolean
release_publish_run_id:
description: Exact parent OpenClaw Release Publish run id when release-orchestrated
required: false
default: ""
type: string
release_publish_run_attempt:
description: Exact parent OpenClaw Release Publish run attempt when release-orchestrated
required: false
default: ""
type: string
outputs:
version:
description: Resolved Docker release version without the v prefix
@@ -45,11 +71,18 @@ env:
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_REGISTRY: docker.io
DOCKERHUB_IMAGE_NAME: openclaw/openclaw
RELEASE_PUBLISH_RUN_ATTEMPT: ${{ inputs.release_publish_run_attempt }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: ${{ inputs.trusted_workflow_allow_prevalidated_ref }}
RELEASE_TOOLING_FULL_REF: ${{ inputs.trusted_workflow_full_ref }}
RELEASE_TOOLING_REF: ${{ inputs.trusted_workflow_ref }}
RELEASE_TOOLING_SHA: ${{ inputs.trusted_workflow_sha }}
jobs:
validate_release_identity:
runs-on: ubuntu-24.04
permissions:
actions: read
contents: read
steps:
- name: Validate immutable tag and SHA inputs
@@ -101,6 +134,21 @@ jobs:
exit 1
fi
- &trusted_release_tooling_checkout
name: Checkout trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.trusted_workflow_sha }}
path: .release-tooling
persist-credentials: false
sparse-checkout: scripts
- &verify_release_tooling_step
name: Revalidate trusted release tooling
env:
GH_TOKEN: ${{ github.token }}
run: node .release-tooling/scripts/release-tooling-identity.mjs verify-env
resolve_release_policy:
needs: validate_release_identity
runs-on: ubuntu-24.04
@@ -113,7 +161,7 @@ jobs:
- name: Checkout trusted workflow helpers
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
ref: ${{ inputs.trusted_workflow_sha }}
path: workflow-source
persist-credentials: false
@@ -206,6 +254,7 @@ jobs:
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
runs-on: ubuntu-24.04
permissions:
actions: read
packages: write
contents: read
outputs:
@@ -218,6 +267,8 @@ jobs:
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
fetch-depth: 0
- *trusted_release_tooling_checkout
- &buildkit_prepull_step
name: Pre-pull BuildKit image
shell: bash
@@ -323,6 +374,8 @@ jobs:
echo "EOF"
} >> "$GITHUB_OUTPUT"
- *verify_release_tooling_step
- name: Build and push amd64 image
id: build
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
@@ -342,6 +395,12 @@ jobs:
provenance: mode=max
push: true
- name: Revalidate trusted release tooling before browser push
if: steps.tags.outputs.browser != ''
env:
GH_TOKEN: ${{ github.token }}
run: node .release-tooling/scripts/release-tooling-identity.mjs verify-env
- name: Build and push amd64 browser image
id: build-browser
if: steps.tags.outputs.browser != ''
@@ -435,6 +494,7 @@ jobs:
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
runs-on: ubuntu-24.04-arm
permissions:
actions: read
packages: write
contents: read
outputs:
@@ -447,6 +507,8 @@ jobs:
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
fetch-depth: 0
- *trusted_release_tooling_checkout
- *buildkit_prepull_step
- name: Set up Docker Builder
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
@@ -533,6 +595,8 @@ jobs:
echo "EOF"
} >> "$GITHUB_OUTPUT"
- *verify_release_tooling_step
- name: Build and push arm64 image
id: build
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
@@ -552,6 +616,12 @@ jobs:
provenance: mode=max
push: true
- name: Revalidate trusted release tooling before browser push
if: steps.tags.outputs.browser != ''
env:
GH_TOKEN: ${{ github.token }}
run: node .release-tooling/scripts/release-tooling-identity.mjs verify-env
- name: Build and push arm64 browser image
id: build-browser
if: steps.tags.outputs.browser != ''
@@ -653,6 +723,7 @@ jobs:
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
runs-on: ubuntu-24.04
permissions:
actions: read
packages: write
contents: read
outputs:
@@ -664,6 +735,8 @@ jobs:
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
fetch-depth: 0
- *trusted_release_tooling_checkout
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
@@ -737,6 +810,7 @@ jobs:
- name: Create and push manifest
shell: bash
env:
GH_TOKEN: ${{ github.token }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
TAGS: ${{ steps.tags.outputs.value }}
@@ -763,6 +837,7 @@ jobs:
[ -z "$tag" ] && continue
args+=("-t" "$tag")
done
node .release-tooling/scripts/release-tooling-identity.mjs verify-env
docker buildx imagetools create "${args[@]}" "$amd64_digest" "$arm64_digest"
}
create_manifest "${AMD64_DIGEST}" "${ARM64_DIGEST}" "${tags[@]}"
@@ -791,6 +866,7 @@ jobs:
outputs:
vcr_source_refs: ${{ steps.vcr_source_refs.outputs.value }}
permissions:
actions: read
contents: read
packages: write
steps:
@@ -800,6 +876,8 @@ jobs:
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
fetch-depth: 1
- *trusted_release_tooling_checkout
- *buildkit_prepull_step
- name: Set up Docker Builder
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
@@ -919,24 +997,24 @@ jobs:
mapfile -t dockerhub_amd64_refs <<< "${DOCKERHUB_AMD64_REFS}"
mapfile -t dockerhub_arm64_refs <<< "${DOCKERHUB_ARM64_REFS}"
node scripts/verify-docker-attestations.mjs \
node .release-tooling/scripts/verify-docker-attestations.mjs \
--platform linux/amd64 \
--platform linux/arm64 \
"${multi_refs[@]}"
node scripts/verify-docker-attestations.mjs \
node .release-tooling/scripts/verify-docker-attestations.mjs \
--platform linux/amd64 \
"${amd64_refs[@]}"
node scripts/verify-docker-attestations.mjs \
node .release-tooling/scripts/verify-docker-attestations.mjs \
--platform linux/arm64 \
"${arm64_refs[@]}"
node scripts/verify-docker-attestations.mjs \
node .release-tooling/scripts/verify-docker-attestations.mjs \
--platform linux/amd64 \
--platform linux/arm64 \
"${dockerhub_multi_refs[@]}"
node scripts/verify-docker-attestations.mjs \
node .release-tooling/scripts/verify-docker-attestations.mjs \
--platform linux/amd64 \
"${dockerhub_amd64_refs[@]}"
node scripts/verify-docker-attestations.mjs \
node .release-tooling/scripts/verify-docker-attestations.mjs \
--platform linux/arm64 \
"${dockerhub_arm64_refs[@]}"
@@ -981,7 +1059,7 @@ jobs:
immutable_refs+=("${immutable_ref}")
done
node scripts/verify-docker-attestations.mjs \
node .release-tooling/scripts/verify-docker-attestations.mjs \
--platform linux/amd64 \
--platform linux/arm64 \
"${immutable_refs[@]}"
@@ -995,13 +1073,14 @@ jobs:
- name: Promote and verify channel aliases
if: ${{ needs.resolve_release_policy.outputs.channel != 'beta' }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.resolve_release_policy.outputs.version }}
IMAGE_TAG_SUFFIX: ${{ inputs.image_tag_suffix }}
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
run: |
set -euo pipefail
node scripts/docker-channel-promote.mjs \
node .release-tooling/scripts/docker-channel-promote.mjs \
--version "${VERSION}" \
--image-tag-suffix "${IMAGE_TAG_SUFFIX}" \
--image "${GHCR_IMAGE}" \
@@ -2556,10 +2556,17 @@ jobs:
with:
tag: ${{ inputs.tag }}
release_sha: ${{ needs.resolve_release_target.outputs.sha }}
release_publish_run_attempt: ${{ github.run_attempt }}
release_publish_run_id: ${{ github.run_id }}
trusted_workflow_allow_prevalidated_ref: ${{ github.ref_type == 'branch' && github.ref_name != 'main' }}
trusted_workflow_full_ref: ${{ github.ref }}
trusted_workflow_ref: ${{ github.ref_name }}
trusted_workflow_sha: ${{ github.workflow_sha }}
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
permissions:
actions: read
contents: read
packages: write
+8 -1
View File
@@ -6,6 +6,7 @@ import { parseArgs } from "node:util";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { resolveDockerReleasePolicy } from "./lib/docker-release-policy.mjs";
import { compareReleaseVersions } from "./lib/release-version.mjs";
import { verifyReleaseToolingIdentityFromEnvironment } from "./release-tooling-identity.mjs";
import { parsePlatform, verifyDockerAttestations } from "./verify-docker-attestations.mjs";
const DOCKER_TIMEOUT_MS = 120_000;
@@ -40,6 +41,7 @@ const VARIANTS = Object.freeze([
* @typedef {object} DockerPromotionOptions
* @property {boolean} [allowRollback]
* @property {DockerExec} [execFileSyncImpl]
* @property {() => void} [beforeMutation]
* @property {(message: string) => void} [log]
* @property {(params: DockerAttestationParams) => void} [verifyAttestationsImpl]
*/
@@ -223,6 +225,7 @@ function preventChannelRollback(resolved, version, execFileSyncImpl) {
*/
export function promoteDockerChannel({ version, imageTagSuffix = "", images }, options = {}) {
const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
const beforeMutation = options.beforeMutation ?? (() => {});
const log = options.log ?? console.log;
const verifyAttestationsImpl = options.verifyAttestationsImpl ?? verifyDockerAttestations;
const plan = createDockerChannelPromotionPlan({
@@ -257,6 +260,7 @@ export function promoteDockerChannel({ version, imageTagSuffix = "", images }, o
for (const promotion of resolved) {
const targetArgs = promotion.targetRefs.flatMap((targetRef) => ["--tag", targetRef]);
beforeMutation();
runDocker(
[
"buildx",
@@ -313,7 +317,10 @@ function main() {
}
const plan = promoteDockerChannel(
{ version, imageTagSuffix: values["image-tag-suffix"], images },
{ allowRollback: values["allow-rollback"] },
{
allowRollback: values["allow-rollback"],
beforeMutation: verifyReleaseToolingIdentityFromEnvironment,
},
);
console.log(`Promoted Docker ${plan.channel} aliases for ${plan.version}.`);
}
+7
View File
@@ -36,6 +36,13 @@ export function verifyReleaseToolingIdentity(
},
): ReleaseToolingIdentity;
export function verifyReleaseToolingIdentityFromEnvironment(
env?: Record<string, string | undefined>,
options?: {
runGh?: (args: string[]) => string;
},
): ReleaseToolingIdentity;
export function validateReleasePublishParentRun(input: {
identity: Pick<ReleaseToolingIdentity, "fullRef" | "ref" | "sha">;
releasePublishRunAttempt: string;
+24
View File
@@ -376,6 +376,23 @@ export function verifyReleaseToolingIdentity({
return validated;
}
export function verifyReleaseToolingIdentityFromEnvironment(env = process.env, { runGh } = {}) {
const allowPrevalidatedRef = env.RELEASE_TOOLING_ALLOW_PREVALIDATED_REF ?? "false";
if (allowPrevalidatedRef !== "true" && allowPrevalidatedRef !== "false") {
fail("RELEASE_TOOLING_ALLOW_PREVALIDATED_REF must be true or false.");
}
return verifyReleaseToolingIdentity({
allowPrevalidatedRef: allowPrevalidatedRef === "true",
releasePublishRunAttempt: env.RELEASE_PUBLISH_RUN_ATTEMPT,
releasePublishRunId: env.RELEASE_PUBLISH_RUN_ID,
repository: env.GITHUB_REPOSITORY,
runGh,
workflowFullRef: env.RELEASE_TOOLING_FULL_REF,
workflowRef: env.RELEASE_TOOLING_REF,
workflowSha: env.RELEASE_TOOLING_SHA,
});
}
function validateParentRunIfRequested({
identity,
releasePublishRunAttempt,
@@ -455,6 +472,13 @@ function parseArgs(argv) {
}
function main(argv = process.argv.slice(2)) {
if (argv[0] === "verify-env") {
if (argv.length !== 1) {
fail("usage: release-tooling-identity.mjs verify-env");
}
process.stdout.write(`${JSON.stringify(verifyReleaseToolingIdentityFromEnvironment())}\n`);
return;
}
const options = parseArgs([...argv]);
let identity;
if (options.command === "resolve") {
+9 -1
View File
@@ -1846,12 +1846,20 @@ NODE
tag: "${{ matrix.tag }}",
release_sha: "${{ matrix.release_sha }}",
image_tag_suffix: "${{ needs.plan.outputs.image_tag_suffix }}",
trusted_workflow_allow_prevalidated_ref: false,
trusted_workflow_full_ref: "${{ github.ref }}",
trusted_workflow_ref: "${{ github.ref_name }}",
trusted_workflow_sha: "${{ github.workflow_sha }}",
});
expect(publish.secrets).toEqual({
DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}",
DOCKERHUB_TOKEN: "${{ secrets.DOCKERHUB_TOKEN }}",
});
expect(publish.permissions).toEqual({ contents: "read", packages: "write" });
expect(publish.permissions).toEqual({
actions: "read",
contents: "read",
packages: "write",
});
expect(releaseWorkflow.jobs.approve_docker_publish.environment).toBe("docker-release");
});
+114 -5
View File
@@ -67,11 +67,17 @@ type WorkflowJob = {
type Workflow = {
concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string };
env?: Record<string, string>;
jobs?: Record<string, WorkflowJob>;
on?: {
workflow_call?: {
inputs?: Record<string, { required?: boolean; type?: string }>;
};
};
};
function readWorkflow(path: string): Workflow {
return parse(readFileSync(path, "utf8")) as Workflow;
function readWorkflow(workflowPath: string): Workflow {
return parse(readFileSync(workflowPath, "utf8")) as Workflow;
}
function requireJob(workflow: Workflow, name: string): WorkflowJob {
@@ -258,6 +264,33 @@ describe("Docker channel promotion", () => {
);
});
it("revalidates trusted tooling immediately before every alias mutation", () => {
const events: string[] = [];
const docker = createDockerMock({
candidateVersion: "2026.6.33",
currentVersion: "2026.6.33",
});
const execFileSyncImpl = vi.fn((command: string, args: string[]) => {
if (args[2] === "create") {
events.push("create");
}
return docker(command, args);
});
const beforeMutation = vi.fn(() => events.push("verify"));
promoteDockerChannel(
{ version: "2026.6.33", images },
{
beforeMutation,
execFileSyncImpl,
verifyAttestationsImpl: skipAttestationVerification,
},
);
expect(beforeMutation).toHaveBeenCalledTimes(6);
expect(events).toEqual(Array.from({ length: 6 }, () => ["verify", "create"]).flat());
});
it("fails without mutating when any version-specific source is missing", () => {
const calls: string[][] = [];
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
@@ -669,7 +702,11 @@ describe("Docker channel promotion", () => {
"cancel-in-progress": false,
queue: "max",
});
expect(verifyAttestations.permissions).toEqual({ contents: "read", packages: "write" });
expect(verifyAttestations.permissions).toEqual({
actions: "read",
contents: "read",
packages: "write",
});
const manifestTagStep = createManifest.steps?.find(
(step) => step.name === "Resolve manifest tags",
@@ -693,7 +730,7 @@ describe("Docker channel promotion", () => {
"${{ needs.resolve_release_policy.outputs.channel != 'beta' }}",
);
expect(releaseSteps[releasePromotionIndex]?.run).toContain(
"node scripts/docker-channel-promote.mjs",
"node .release-tooling/scripts/docker-channel-promote.mjs",
);
expect(releaseSteps[releasePromotionIndex]?.run).not.toContain("--allow-rollback");
expect(
@@ -710,7 +747,11 @@ describe("Docker channel promotion", () => {
expect(approve.environment).toBe("docker-release");
expect(approve.permissions).toEqual({});
expect(promote.needs).toEqual(["resolve", "approve"]);
expect(promote.permissions).toEqual({ contents: "read", packages: "write" });
expect(promote.permissions).toEqual({
actions: "read",
contents: "read",
packages: "write",
});
expect(promote.concurrency).toEqual({
group: "docker-release-publish",
"cancel-in-progress": false,
@@ -728,6 +769,7 @@ describe("Docker channel promotion", () => {
expect(promotionIndex).toBeGreaterThan(-1);
expect(steps[promotionIndex]?.run).toContain("node scripts/docker-channel-promote.mjs");
expect(steps[promotionIndex]?.run).toContain("--allow-rollback");
expect(steps[promotionIndex]?.env?.GH_TOKEN).toBe("${{ github.token }}");
const packageWriters = Object.entries(workflow.jobs ?? {}).filter(
([, job]) => job.permissions?.packages === "write",
@@ -735,4 +777,71 @@ describe("Docker channel promotion", () => {
expect(packageWriters.map(([name]) => name)).toEqual(["promote"]);
expect(packageWriters[0]?.[1].needs).toContain("approve");
});
it("binds every Docker registry mutation to live trusted tooling", () => {
const workflow = readWorkflow(".github/workflows/docker-release.yml");
expect(workflow.on?.workflow_call?.inputs).toMatchObject({
release_publish_run_attempt: { required: false, type: "string" },
release_publish_run_id: { required: false, type: "string" },
trusted_workflow_allow_prevalidated_ref: { required: true, type: "boolean" },
trusted_workflow_full_ref: { required: true, type: "string" },
trusted_workflow_ref: { required: true, type: "string" },
trusted_workflow_sha: { required: true, type: "string" },
});
expect(workflow.env).toMatchObject({
RELEASE_PUBLISH_RUN_ATTEMPT: "${{ inputs.release_publish_run_attempt }}",
RELEASE_PUBLISH_RUN_ID: "${{ inputs.release_publish_run_id }}",
RELEASE_TOOLING_ALLOW_PREVALIDATED_REF:
"${{ inputs.trusted_workflow_allow_prevalidated_ref }}",
RELEASE_TOOLING_FULL_REF: "${{ inputs.trusted_workflow_full_ref }}",
RELEASE_TOOLING_REF: "${{ inputs.trusted_workflow_ref }}",
RELEASE_TOOLING_SHA: "${{ inputs.trusted_workflow_sha }}",
});
for (const jobName of ["build-amd64", "build-arm64"]) {
const steps = requireJob(workflow, jobName).steps ?? [];
const trustedCheckout = steps.find(
(step) => step.name === "Checkout trusted release tooling",
);
expect(trustedCheckout?.with).toMatchObject({
path: ".release-tooling",
ref: "${{ inputs.trusted_workflow_sha }}",
});
for (const writeStepName of [
`Build and push ${jobName === "build-amd64" ? "amd64" : "arm64"} image`,
`Build and push ${jobName === "build-amd64" ? "amd64" : "arm64"} browser image`,
]) {
const writeIndex = steps.findIndex((step) => step.name === writeStepName);
expect(writeIndex).toBeGreaterThan(0);
expect(steps[writeIndex - 1]?.run).toBe(
"node .release-tooling/scripts/release-tooling-identity.mjs verify-env",
);
}
}
const manifest = requireStep(
requireJob(workflow, "create-manifest"),
"Create and push manifest",
);
expect(manifest.env?.GH_TOKEN).toBe("${{ github.token }}");
expect(manifest.run).toMatch(
/node \.release-tooling\/scripts\/release-tooling-identity\.mjs verify-env\n\s+docker buildx imagetools create "\$\{args\[@\]\}"/u,
);
const attestations = requireStep(
requireJob(workflow, "verify-attestations"),
"Verify Docker attestations",
);
expect(attestations.run).toContain(
"node .release-tooling/scripts/verify-docker-attestations.mjs",
);
expect(attestations.run).not.toMatch(/(?:^|\n)\s*node scripts\/verify-docker-attestations/u);
const promotion = requireStep(
requireJob(workflow, "verify-attestations"),
"Promote and verify channel aliases",
);
expect(promotion.env?.GH_TOKEN).toBe("${{ github.token }}");
expect(promotion.run).toContain("node .release-tooling/scripts/docker-channel-promote.mjs");
});
});
@@ -1043,6 +1043,13 @@ describe("release validation no-push transport", () => {
expect(dockerCall.with).toEqual({
tag: "${{ inputs.tag }}",
release_sha: "${{ needs.resolve_release_target.outputs.sha }}",
release_publish_run_attempt: "${{ github.run_attempt }}",
release_publish_run_id: "${{ github.run_id }}",
trusted_workflow_allow_prevalidated_ref:
"${{ github.ref_type == 'branch' && github.ref_name != 'main' }}",
trusted_workflow_full_ref: "${{ github.ref }}",
trusted_workflow_ref: "${{ github.ref_name }}",
trusted_workflow_sha: "${{ github.workflow_sha }}",
});
expect(dockerCall.secrets).toEqual({
DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}",
@@ -3,6 +3,7 @@ import {
resolveReleaseToolingIdentity,
validateReleaseToolingIdentity,
verifyReleaseToolingIdentity,
verifyReleaseToolingIdentityFromEnvironment,
} from "../../scripts/release-tooling-identity.mjs";
const SHA = "a".repeat(40);
@@ -26,6 +27,58 @@ function protectedIdentity(
}
describe("release tooling identity", () => {
it("verifies the complete mutation-boundary identity from environment state", () => {
const runGh = vi.fn((args: string[]) => {
if (args[1]?.includes("/compare/")) {
return JSON.stringify({ status: "identical" });
}
if (args[1]?.includes("/actions/runs/")) {
return JSON.stringify({
event: "workflow_dispatch",
head_branch: "main",
head_sha: SHA,
id: Number(PARENT_RUN_ID),
path: ".github/workflows/openclaw-release-publish.yml@refs/heads/main",
repository: { full_name: "openclaw/openclaw" },
run_attempt: Number(PARENT_RUN_ATTEMPT),
});
}
throw new Error(`Unexpected gh call: ${args.join(" ")}`);
});
expect(
verifyReleaseToolingIdentityFromEnvironment(
{
GITHUB_REPOSITORY: "openclaw/openclaw",
RELEASE_PUBLISH_RUN_ATTEMPT: PARENT_RUN_ATTEMPT,
RELEASE_PUBLISH_RUN_ID: PARENT_RUN_ID,
RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "false",
RELEASE_TOOLING_FULL_REF: "refs/heads/main",
RELEASE_TOOLING_REF: "main",
RELEASE_TOOLING_SHA: SHA,
},
{ runGh },
),
).toEqual({
fullRef: "refs/heads/main",
ref: "main",
route: "main",
sha: SHA,
});
});
it("rejects ambiguous mutation-boundary prevalidated-ref policy", () => {
expect(() =>
verifyReleaseToolingIdentityFromEnvironment({
GITHUB_REPOSITORY: "openclaw/openclaw",
RELEASE_TOOLING_ALLOW_PREVALIDATED_REF: "yes",
RELEASE_TOOLING_FULL_REF: "refs/heads/main",
RELEASE_TOOLING_REF: "main",
RELEASE_TOOLING_SHA: SHA,
}),
).toThrow("RELEASE_TOOLING_ALLOW_PREVALIDATED_REF must be true or false");
});
it.each([
["1", "main", "refs/heads/main"],
["2", "release/2026.8.1", "refs/heads/release/2026.8.1"],