mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(release): bind Docker writers to trusted tooling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}.`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user