fix(release): bind VCR writers to trusted tooling

This commit is contained in:
Vincent Koc
2026-08-20 21:20:01 -07:00
parent 4bad79f6ab
commit f39fdcd4ae
5 changed files with 160 additions and 14 deletions
@@ -2577,11 +2577,18 @@ jobs:
uses: ./.github/workflows/vercel-container-registry-publish.yml
with:
include_browser: ${{ needs.publish_docker.outputs.include_browser == 'true' }}
release_publish_run_attempt: ${{ github.run_attempt }}
release_publish_run_id: ${{ github.run_id }}
source_refs: ${{ needs.publish_docker.outputs.vcr_source_refs }}
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 }}
version: ${{ needs.publish_docker.outputs.version }}
secrets:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
permissions:
actions: read
contents: read
finalize_github_release:
@@ -15,6 +15,30 @@ on:
description: Whether the tagged Docker release includes browser images
required: true
type: boolean
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
required: true
type: string
release_publish_run_attempt:
description: Exact parent OpenClaw Release Publish run attempt
required: true
type: string
secrets:
VERCEL_TOKEN:
required: true
@@ -22,6 +46,12 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
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 }}
concurrency:
# Alias promotion is read-then-write. Serialize VCR independently so a slow
@@ -37,14 +67,20 @@ jobs:
# Docker publication and GitHub release finalization must continue.
continue-on-error: true
permissions:
actions: read
contents: read
steps:
- name: Checkout trusted registry tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
ref: ${{ inputs.trusted_workflow_sha }}
persist-credentials: false
- name: Validate trusted release tooling
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/release-tooling-identity.mjs verify-env
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -100,6 +136,7 @@ jobs:
- name: Copy and verify immutable release images
id: copy_images
env:
GH_TOKEN: ${{ github.token }}
INCLUDE_BROWSER: ${{ inputs.include_browser }}
SOURCE_REFS: ${{ inputs.source_refs }}
TARGET_IMAGE: ${{ vars.VERCEL_VCR_IMAGE }}
@@ -180,6 +217,7 @@ jobs:
- name: Promote and verify channel aliases
id: promote_aliases
env:
GH_TOKEN: ${{ github.token }}
INCLUDE_BROWSER: ${{ inputs.include_browser }}
TARGET_IMAGE: ${{ vars.VERCEL_VCR_IMAGE }}
VERSION: ${{ inputs.version }}
@@ -22,6 +22,7 @@ export function publishVercelContainerRegistryImages(
targetImage: string;
},
options?: {
beforeMutation?: () => void;
execFileSyncImpl?: (command: string, args: string[], options: object) => unknown;
log?: (message: string) => void;
},
@@ -34,6 +35,7 @@ export function promoteVercelContainerRegistryAliases(
targetImage: string;
},
options?: {
beforeMutation?: () => void;
execFileSyncImpl?: (command: string, args: string[], options: object) => unknown;
log?: (message: string) => void;
},
+27 -11
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";
const IMAGETOOLS_TIMEOUT_MS = 20 * 60_000;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
@@ -277,6 +278,7 @@ function verifyCleanIndex(imageRef, expectedDigests, execFileSyncImpl) {
/** Publish every immutable release tag with byte-identical platform manifests. */
export function publishVercelContainerRegistryImages(params, options = {}) {
const beforeMutation = options.beforeMutation ?? (() => {});
const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
const log = options.log ?? console.log;
const immutableSources = parseImmutableSourceRefs(params.sourceRefs, params.includeBrowser);
@@ -305,11 +307,14 @@ export function publishVercelContainerRegistryImages(params, options = {}) {
return { manifestTag, platformDigests };
});
// A prevalidated tooling branch can move during a multi-variant copy. Recheck
// the exact approved identity at each registry write, not once per step.
for (const { manifestTag, platformDigests } of variants) {
const manifestTargetRef = `${plan.targetImage}:${manifestTag}`;
const platformSourceRefs = ARCHITECTURES.map(
(architecture) => `${plan.sourceImage}@${platformDigests[architecture]}`,
);
beforeMutation();
runImagetools(
["create", "--progress", "plain", "--tag", manifestTargetRef, ...platformSourceRefs],
execFileSyncImpl,
@@ -326,6 +331,7 @@ export function publishVercelContainerRegistryImages(params, options = {}) {
for (const architecture of ARCHITECTURES) {
const targetRef = `${plan.targetImage}:${manifestTag}-${architecture}`;
const sourceDigest = platformDigests[architecture];
beforeMutation();
runImagetools(
[
"create",
@@ -357,6 +363,7 @@ export function publishVercelContainerRegistryImages(params, options = {}) {
/** Promote moving aliases only after Sandbox proves the immutable image is ready. */
export function promoteVercelContainerRegistryAliases(params, options = {}) {
const beforeMutation = options.beforeMutation ?? (() => {});
const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
const log = options.log ?? console.log;
const policy = resolveDockerReleasePolicy(params.version);
@@ -417,9 +424,12 @@ export function promoteVercelContainerRegistryAliases(params, options = {}) {
}
}
// Alias promotion runs after the potentially long Sandbox smoke. Recheck at
// each write so elapsed time cannot turn stale tooling into a live publisher.
for (const { manifestDigest, manifestTag, targetRefs } of promotions) {
const targetArgs = targetRefs.flatMap((targetRef) => ["--tag", targetRef]);
const sourceDigestRef = `${targetImage}@${manifestDigest}`;
beforeMutation();
runImagetools(
["create", "--prefer-index=false", ...targetArgs, sourceDigestRef],
execFileSyncImpl,
@@ -475,23 +485,29 @@ function main() {
if (values["source-ref"]) {
throw new Error("--promote-aliases cannot be combined with --source-ref.");
}
const result = promoteVercelContainerRegistryAliases({
includeBrowser: values["include-browser"] ?? false,
targetImage: values["target-image"],
version: values.version,
});
const result = promoteVercelContainerRegistryAliases(
{
includeBrowser: values["include-browser"] ?? false,
targetImage: values["target-image"],
version: values.version,
},
{ beforeMutation: verifyReleaseToolingIdentityFromEnvironment },
);
console.log(`Promoted ${result.channel} aliases for ${result.targetImage}:${result.version}.`);
return;
}
if (!values["source-ref"]) {
throw new Error("--source-ref is required when publishing immutable images.");
}
const plan = publishVercelContainerRegistryImages({
includeBrowser: values["include-browser"] ?? false,
sourceRefs: values["source-ref"],
targetImage: values["target-image"],
version: values.version,
});
const plan = publishVercelContainerRegistryImages(
{
includeBrowser: values["include-browser"] ?? false,
sourceRefs: values["source-ref"],
targetImage: values["target-image"],
version: values.version,
},
{ beforeMutation: verifyReleaseToolingIdentityFromEnvironment },
);
console.log(
`Published ${plan.copies.length} immutable ${plan.channel} tags to ${plan.targetImage}.`,
);
@@ -50,6 +50,7 @@ type WorkflowJob = {
type Workflow = {
concurrency?: { group?: string; "cancel-in-progress"?: boolean; queue?: string };
env?: Record<string, string>;
jobs?: Record<string, WorkflowJob>;
on?: {
workflow_call?: {
@@ -59,8 +60,8 @@ type Workflow = {
};
};
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 {
@@ -270,6 +271,27 @@ describe("Vercel Container Registry publishing", () => {
]);
});
it("revalidates trusted tooling before every immutable VCR write", () => {
const events: string[] = [];
const docker = successfulExecutor([]);
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"));
publishVercelContainerRegistryImages(publishParams("2026.7.2", true), {
beforeMutation,
execFileSyncImpl,
log: () => {},
});
expect(beforeMutation).toHaveBeenCalledTimes(9);
expect(events).toEqual(Array.from({ length: 9 }, () => ["verify", "create"]).flat());
});
it("fails before writing when an immutable source is missing", () => {
const calls: string[][] = [];
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
@@ -372,6 +394,30 @@ describe("Vercel Container Registry publishing", () => {
]);
});
it("revalidates trusted tooling before every VCR alias promotion", () => {
const events: string[] = [];
const docker = successfulExecutor([]);
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"));
promoteVercelContainerRegistryAliases(
{
includeBrowser: true,
targetImage,
version: "2026.7.2",
},
{ beforeMutation, execFileSyncImpl, log: () => {} },
);
expect(beforeMutation).toHaveBeenCalledTimes(3);
expect(events).toEqual(Array.from({ length: 3 }, () => ["verify", "create"]).flat());
});
it("refuses to move a VCR channel alias backward", () => {
const calls: string[][] = [];
const execFileSyncImpl = successfulExecutor(calls, {
@@ -420,16 +466,34 @@ describe("Vercel Container Registry publishing", () => {
expect(releasePublish.uses).toBe("./.github/workflows/vercel-container-registry-publish.yml");
expect(releasePublish.with).toMatchObject({
include_browser: "${{ needs.publish_docker.outputs.include_browser == 'true' }}",
release_publish_run_attempt: "${{ github.run_attempt }}",
release_publish_run_id: "${{ github.run_id }}",
source_refs: "${{ needs.publish_docker.outputs.vcr_source_refs }}",
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 }}",
version: "${{ needs.publish_docker.outputs.version }}",
});
expect(releasePublish.secrets).toEqual({
VERCEL_TOKEN: "${{ secrets.VERCEL_TOKEN }}",
});
expect(releasePublish.permissions).toEqual({ actions: "read", contents: "read" });
expect(finalizeRelease.needs).toEqual(["publish", "publish_docker"]);
expect(finalizeRelease.if).not.toContain("publish_vcr");
expect(reusablePublish["continue-on-error"]).toBe(true);
expect(reusablePublish["timeout-minutes"]).toBe(30);
expect(reusablePublish.permissions).toEqual({ actions: "read", contents: "read" });
expect(reusable.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 }}",
});
const validateDispatch = manualResolve.steps?.find((step) =>
step.name?.includes("main-branch dispatch"),
@@ -461,6 +525,14 @@ describe("Vercel Container Registry publishing", () => {
required: true,
type: "string",
});
expect(reusable.on?.workflow_call?.inputs).toMatchObject({
release_publish_run_attempt: { required: true, type: "string" },
release_publish_run_id: { required: true, 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(verifyAttestations.outputs?.vcr_source_refs).toBe(
"${{ steps.vcr_source_refs.outputs.value }}",
);
@@ -485,6 +557,15 @@ describe("Vercel Container Registry publishing", () => {
expect(reusablePublish.steps?.find((step) => step.name === "Set up Docker Builder")?.uses).toBe(
"docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5",
);
const trustedCheckout = reusablePublish.steps?.find(
(step) => step.name === "Checkout trusted registry tooling",
);
expect(trustedCheckout?.with?.ref).toBe("${{ inputs.trusted_workflow_sha }}");
const toolingValidation = reusablePublish.steps?.find(
(step) => step.name === "Validate trusted release tooling",
);
expect(toolingValidation?.env?.GH_TOKEN).toBe("${{ github.token }}");
expect(toolingValidation?.run).toBe("node scripts/release-tooling-identity.mjs verify-env");
const materializeVercel = reusablePublish.steps?.find(
(step) => step.name === "Materialize locked Vercel CLI",
);
@@ -508,6 +589,8 @@ describe("Vercel Container Registry publishing", () => {
expect(copyIndex).toBeGreaterThan(-1);
expect(smokeIndex).toBeGreaterThan(copyIndex ?? -1);
expect(promoteIndex).toBeGreaterThan(smokeIndex ?? -1);
expect(reusablePublish.steps?.[copyIndex ?? -1]?.env?.GH_TOKEN).toBe("${{ github.token }}");
expect(reusablePublish.steps?.[promoteIndex ?? -1]?.env?.GH_TOKEN).toBe("${{ github.token }}");
const smokeRun = reusablePublish.steps?.[smokeIndex ?? -1]?.run ?? "";
expect(smokeRun).toContain("sandbox run \\\n");
expect(smokeRun).toContain("image_not_ready");