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