mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(release): harden Docker channel promotion
This commit is contained in:
@@ -162,26 +162,6 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Verify immutable source attestations
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_refs=(
|
||||
"${GHCR_IMAGE}:${VERSION}"
|
||||
"${GHCR_IMAGE}:${VERSION}-slim"
|
||||
"${GHCR_IMAGE}:${VERSION}-browser"
|
||||
"${DOCKERHUB_IMAGE}:${VERSION}"
|
||||
"${DOCKERHUB_IMAGE}:${VERSION}-slim"
|
||||
"${DOCKERHUB_IMAGE}:${VERSION}-browser"
|
||||
)
|
||||
node scripts/verify-docker-attestations.mjs \
|
||||
--platform linux/amd64 \
|
||||
--platform linux/arm64 \
|
||||
"${source_refs[@]}"
|
||||
|
||||
- name: Promote and verify channel aliases
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
@@ -192,9 +172,11 @@ jobs:
|
||||
node scripts/docker-channel-promote.mjs \
|
||||
--version "${VERSION}" \
|
||||
--image "${GHCR_IMAGE}" \
|
||||
--image "${DOCKERHUB_IMAGE}"
|
||||
--image "${DOCKERHUB_IMAGE}" \
|
||||
--allow-rollback
|
||||
{
|
||||
echo "## Docker channel promotion"
|
||||
echo "- Version: ${VERSION}"
|
||||
echo "- Registries: ${GHCR_IMAGE}, ${DOCKERHUB_IMAGE}"
|
||||
echo "- Rollback: explicitly approved"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+6
-5
@@ -164,14 +164,15 @@ gh workflow run ci.yml --ref main -f target_ref=<branch-or-sha> -f include_andro
|
||||
gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
```
|
||||
|
||||
The monthly npm-only extended-stable path is the exception: dispatch both `OpenClaw NPM
|
||||
The monthly Gateway extended-stable path is the exception: dispatch both `OpenClaw NPM
|
||||
Release` preflight and `Full Release Validation` from the exact
|
||||
`extended-stable/YYYY.M.33` branch, preserve their run IDs, and pass both IDs to the
|
||||
direct npm publish run. See [Monthly npm-only extended-stable
|
||||
publication](/reference/RELEASING#monthly-npm-only-extended-stable-publication) for
|
||||
direct npm publish run. See [Monthly Gateway extended-stable
|
||||
publication](/reference/RELEASING#monthly-gateway-extended-stable-publication) for
|
||||
the commands, exact identity requirements, registry readback, and selector
|
||||
repair procedure. This path does not dispatch plugin, macOS, Windows, GitHub
|
||||
Release, private dist-tag, or other platform publication.
|
||||
repair procedure. It publishes the `openclaw` npm package, official npm plugins,
|
||||
and Gateway Docker images. It does not publish the macOS app, Windows Hub,
|
||||
mobile apps, a GitHub Release, ClawHub packages, or website artifacts.
|
||||
|
||||
## Runners
|
||||
|
||||
|
||||
@@ -20,7 +20,18 @@ export function createDockerChannelPromotionPlan(params: {
|
||||
export function promoteDockerChannel(
|
||||
params: { version: string; images: string[] },
|
||||
options?: {
|
||||
allowRollback?: boolean;
|
||||
execFileSyncImpl?: (command: string, args: string[], options: object) => string;
|
||||
log?: (message: string) => void;
|
||||
verifyAttestationsImpl?: (params: {
|
||||
imageRefs: string[];
|
||||
requiredPlatforms: Array<{
|
||||
architecture: string;
|
||||
os: string;
|
||||
variant?: string;
|
||||
}>;
|
||||
execFileSyncImpl: (command: string, args: string[], options: object) => string;
|
||||
log: (message: string) => void;
|
||||
}) => void;
|
||||
},
|
||||
): DockerChannelPromotionPlan;
|
||||
|
||||
@@ -5,15 +5,21 @@ import process from "node:process";
|
||||
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 { parsePlatform, verifyDockerAttestations } from "./verify-docker-attestations.mjs";
|
||||
|
||||
const DOCKER_TIMEOUT_MS = 120_000;
|
||||
const REQUIRED_PLATFORMS = Object.freeze([
|
||||
parsePlatform("linux/amd64"),
|
||||
parsePlatform("linux/arm64"),
|
||||
]);
|
||||
const VARIANTS = Object.freeze([
|
||||
{ aliasKey: "default", suffix: "" },
|
||||
{ aliasKey: "slim", suffix: "-slim" },
|
||||
{ aliasKey: "browser", suffix: "-browser" },
|
||||
]);
|
||||
|
||||
/** Build the immutable-source to moving-alias promotion plan. */
|
||||
/** Build the version-specific source to moving-alias promotion plan. */
|
||||
export function createDockerChannelPromotionPlan({ version, images }) {
|
||||
if (images.length === 0) {
|
||||
throw new Error("At least one --image is required.");
|
||||
@@ -66,18 +72,147 @@ function inspectManifestDigest(imageRef, execFileSyncImpl) {
|
||||
return digest;
|
||||
}
|
||||
|
||||
function formatCommandError(error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return String(error);
|
||||
}
|
||||
const output = [error.message];
|
||||
for (const field of ["stderr", "stdout"]) {
|
||||
const value = error[field];
|
||||
if (typeof value === "string") {
|
||||
output.push(value);
|
||||
} else if (Buffer.isBuffer(value)) {
|
||||
output.push(value.toString("utf8"));
|
||||
}
|
||||
}
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
function isMissingManifestError(error) {
|
||||
const message = formatCommandError(error);
|
||||
return /(?:manifest unknown|no such manifest|:\s*not found(?:\s|$))/i.test(message);
|
||||
}
|
||||
|
||||
function formatPlatform(platform) {
|
||||
const suffix = platform.variant ? `/${platform.variant}` : "";
|
||||
return `${platform.os}/${platform.architecture}${suffix}`;
|
||||
}
|
||||
|
||||
function inspectImageVersion(imageRef, execFileSyncImpl, { allowMissing = false } = {}) {
|
||||
const versions = new Map();
|
||||
for (const [index, platform] of REQUIRED_PLATFORMS.entries()) {
|
||||
const platformName = formatPlatform(platform);
|
||||
let raw;
|
||||
try {
|
||||
// In formatted multi-platform inspection, Buildx keys .Image by os/arch.
|
||||
// Read every promoted platform rather than trusting one config label.
|
||||
raw = runDocker(
|
||||
[
|
||||
"buildx",
|
||||
"imagetools",
|
||||
"inspect",
|
||||
imageRef,
|
||||
"--format",
|
||||
`{{json (index .Image "${platformName}")}}`,
|
||||
],
|
||||
execFileSyncImpl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (allowMissing && index === 0 && isMissingManifestError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let version;
|
||||
try {
|
||||
version = JSON.parse(raw)?.config?.Labels?.["org.opencontainers.image.version"];
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse the ${platformName} image config for ${imageRef}.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (typeof version !== "string" || version.trim().length === 0) {
|
||||
throw new Error(
|
||||
`${imageRef} does not have an org.opencontainers.image.version label for ${platformName}.`,
|
||||
);
|
||||
}
|
||||
versions.set(platformName, version.trim());
|
||||
}
|
||||
const uniqueVersions = new Set(versions.values());
|
||||
if (uniqueVersions.size !== 1) {
|
||||
const details = [...versions].map(([platform, version]) => `${platform}=${version}`).join(", ");
|
||||
throw new Error(`${imageRef} has inconsistent platform versions: ${details}.`);
|
||||
}
|
||||
return uniqueVersions.values().next().value;
|
||||
}
|
||||
|
||||
function verifySourceVersions(resolved, version, execFileSyncImpl) {
|
||||
for (const promotion of resolved) {
|
||||
const sourceVersion = inspectImageVersion(promotion.sourceDigestRef, execFileSyncImpl);
|
||||
if (sourceVersion !== version) {
|
||||
throw new Error(
|
||||
`${promotion.sourceDigestRef} reports version ${sourceVersion}, expected ${version}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function preventChannelRollback(resolved, version, execFileSyncImpl) {
|
||||
for (const promotion of resolved) {
|
||||
for (const targetRef of promotion.targetRefs) {
|
||||
const currentVersion = inspectImageVersion(targetRef, execFileSyncImpl, {
|
||||
allowMissing: true,
|
||||
});
|
||||
if (currentVersion === null) {
|
||||
continue;
|
||||
}
|
||||
const comparison = compareReleaseVersions(version, currentVersion);
|
||||
if (comparison === null) {
|
||||
throw new Error(
|
||||
`Cannot compare candidate version ${version} with ${targetRef} version ${currentVersion}.`,
|
||||
);
|
||||
}
|
||||
if (comparison < 0) {
|
||||
throw new Error(
|
||||
`Refusing to move ${targetRef} backward from ${currentVersion} to ${version}. ` +
|
||||
"An approved repair may rerun with --allow-rollback.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Promote every planned alias and verify the registry result. */
|
||||
export function promoteDockerChannel({ version, images }, options = {}) {
|
||||
const execFileSyncImpl = options.execFileSyncImpl ?? execFileSync;
|
||||
const log = options.log ?? console.log;
|
||||
const verifyAttestationsImpl = options.verifyAttestationsImpl ?? verifyDockerAttestations;
|
||||
const plan = createDockerChannelPromotionPlan({ version, images });
|
||||
|
||||
// Resolve every immutable source before the first alias write. A missing
|
||||
// Resolve every version-specific source before the first alias write. A missing
|
||||
// release variant must not leave the channel partially promoted.
|
||||
const resolved = plan.promotions.map((promotion) => ({
|
||||
...promotion,
|
||||
sourceDigest: inspectManifestDigest(promotion.sourceRef, execFileSyncImpl),
|
||||
}));
|
||||
const resolved = plan.promotions.map((promotion) => {
|
||||
const sourceDigest = inspectManifestDigest(promotion.sourceRef, execFileSyncImpl);
|
||||
return {
|
||||
...promotion,
|
||||
sourceDigest,
|
||||
sourceDigestRef: `${promotion.image}@${sourceDigest}`,
|
||||
};
|
||||
});
|
||||
|
||||
// Attestation checks and writes share these digest refs so a concurrent tag
|
||||
// rewrite cannot swap the content between verification and promotion.
|
||||
verifyAttestationsImpl({
|
||||
imageRefs: resolved.map((promotion) => promotion.sourceDigestRef),
|
||||
requiredPlatforms: REQUIRED_PLATFORMS,
|
||||
execFileSyncImpl,
|
||||
log,
|
||||
});
|
||||
verifySourceVersions(resolved, plan.version, execFileSyncImpl);
|
||||
if (!options.allowRollback) {
|
||||
preventChannelRollback(resolved, plan.version, execFileSyncImpl);
|
||||
}
|
||||
|
||||
for (const promotion of resolved) {
|
||||
const targetArgs = promotion.targetRefs.flatMap((targetRef) => ["--tag", targetRef]);
|
||||
@@ -88,7 +223,7 @@ export function promoteDockerChannel({ version, images }, options = {}) {
|
||||
"create",
|
||||
"--prefer-index=false",
|
||||
...targetArgs,
|
||||
`${promotion.image}@${promotion.sourceDigest}`,
|
||||
promotion.sourceDigestRef,
|
||||
],
|
||||
execFileSyncImpl,
|
||||
);
|
||||
@@ -107,7 +242,7 @@ 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]",
|
||||
"Usage: node scripts/docker-channel-promote.mjs --version YYYY.M.P --image REGISTRY/IMAGE [--image REGISTRY/IMAGE] [--allow-rollback]",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,6 +250,7 @@ function main() {
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
"allow-rollback": { type: "boolean" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
image: { type: "string", multiple: true },
|
||||
version: { type: "string" },
|
||||
@@ -133,7 +269,10 @@ function main() {
|
||||
if (images.length === 0 || images.some((image) => image.length === 0)) {
|
||||
throw new Error("At least one non-empty --image is required.");
|
||||
}
|
||||
const plan = promoteDockerChannel({ version, images });
|
||||
const plan = promoteDockerChannel(
|
||||
{ version, images },
|
||||
{ allowRollback: values["allow-rollback"] },
|
||||
);
|
||||
console.log(`Promoted Docker ${plan.channel} aliases for ${plan.version}.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ export function parsePlatform(value: unknown): {
|
||||
* Collects missing/mismatched attestation errors for required image platforms.
|
||||
*/
|
||||
export function collectDockerAttestationErrors(params: unknown): string[];
|
||||
export function verifyDockerAttestations(params: {
|
||||
imageRefs: string[];
|
||||
requiredPlatforms: Array<{
|
||||
architecture: string;
|
||||
os: string;
|
||||
variant?: string;
|
||||
}>;
|
||||
execFileSyncImpl?: (command: string, args: string[], options: unknown) => string;
|
||||
log?: (message: string) => void;
|
||||
}): void;
|
||||
export function inspectRaw(
|
||||
imageRef: unknown,
|
||||
params?: {
|
||||
|
||||
@@ -40,6 +40,43 @@ function formatPlatform(platform) {
|
||||
: `${platform.os}/${platform.architecture}`;
|
||||
}
|
||||
|
||||
/** Verify required Docker attestations for every image reference. */
|
||||
export function verifyDockerAttestations(params) {
|
||||
const {
|
||||
imageRefs,
|
||||
requiredPlatforms,
|
||||
execFileSyncImpl = execFileSync,
|
||||
log = console.log,
|
||||
} = params;
|
||||
const allErrors = [];
|
||||
for (const imageRef of imageRefs) {
|
||||
const index = parseJson(inspectRaw(imageRef, { execFileSyncImpl }), `${imageRef} index`);
|
||||
const errors = collectDockerAttestationErrors({
|
||||
imageRef,
|
||||
index,
|
||||
requiredPlatforms,
|
||||
inspectAttestation(digest) {
|
||||
return parseJson(
|
||||
inspectRaw(imageRefForDigest(imageRef, digest), { execFileSyncImpl }),
|
||||
`${imageRef} attestation ${digest}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (errors.length === 0) {
|
||||
log(
|
||||
`Verified Docker attestations for ${imageRef}: ${requiredPlatforms
|
||||
.map(formatPlatform)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
allErrors.push(...errors);
|
||||
}
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
throw new Error(allErrors.map((error) => `[docker-attestations] ${error}`).join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
function platformMatches(actual, expected) {
|
||||
return (
|
||||
actual?.os === expected.os &&
|
||||
@@ -184,36 +221,10 @@ async function main() {
|
||||
throw new Error("At least one --platform is required.");
|
||||
}
|
||||
|
||||
const allErrors = [];
|
||||
for (const imageRef of parsed.imageRefs) {
|
||||
const index = parseJson(inspectRaw(imageRef), `${imageRef} index`);
|
||||
const errors = collectDockerAttestationErrors({
|
||||
imageRef,
|
||||
index,
|
||||
requiredPlatforms: parsed.requiredPlatforms,
|
||||
inspectAttestation(digest) {
|
||||
return parseJson(
|
||||
inspectRaw(imageRefForDigest(imageRef, digest)),
|
||||
`${imageRef} attestation ${digest}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (errors.length === 0) {
|
||||
console.log(
|
||||
`Verified Docker attestations for ${imageRef}: ${parsed.requiredPlatforms
|
||||
.map(formatPlatform)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
allErrors.push(...errors);
|
||||
}
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
for (const error of allErrors) {
|
||||
console.error(`[docker-attestations] ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
verifyDockerAttestations({
|
||||
imageRefs: parsed.imageRefs,
|
||||
requiredPlatforms: parsed.requiredPlatforms,
|
||||
});
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
|
||||
@@ -8,6 +8,42 @@ import {
|
||||
|
||||
const images = ["ghcr.io/openclaw/openclaw", "docker.io/openclaw/openclaw"];
|
||||
const digest = `sha256:${"1".repeat(64)}`;
|
||||
const changedDigest = `sha256:${"2".repeat(64)}`;
|
||||
|
||||
function imageConfig(version: string): string {
|
||||
return JSON.stringify({
|
||||
config: { Labels: { "org.opencontainers.image.version": version } },
|
||||
});
|
||||
}
|
||||
|
||||
function createDockerMock(params: {
|
||||
candidateVersion: string;
|
||||
currentVersion?: string;
|
||||
wrongTargetDigest?: string;
|
||||
}) {
|
||||
const targetDigests = new Map<string, string>();
|
||||
return vi.fn((_command: string, args: string[]) => {
|
||||
if (args[2] === "inspect") {
|
||||
const ref = args[3]!;
|
||||
if (args.at(-1)?.includes(".Image")) {
|
||||
return imageConfig(ref.includes("@") ? params.candidateVersion : params.currentVersion!);
|
||||
}
|
||||
if (params.wrongTargetDigest && ref.includes(":extended-stable")) {
|
||||
return JSON.stringify({ digest: params.wrongTargetDigest });
|
||||
}
|
||||
return JSON.stringify({ digest: targetDigests.get(ref) ?? digest });
|
||||
}
|
||||
const sourceDigest = args.at(-1)!.split("@")[1]!;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] === "--tag") {
|
||||
targetDigests.set(args[index + 1]!, sourceDigest);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
const skipAttestationVerification = () => {};
|
||||
|
||||
type WorkflowStep = {
|
||||
env?: Record<string, string>;
|
||||
@@ -70,27 +106,41 @@ describe("Docker channel promotion", () => {
|
||||
|
||||
it("preflights every source before moving and verifying aliases", () => {
|
||||
const calls: string[][] = [];
|
||||
const targetDigests = new Map<string, string>();
|
||||
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
|
||||
calls.push(args);
|
||||
if (args[2] === "inspect") {
|
||||
return JSON.stringify({ digest: targetDigests.get(args[3]!) ?? digest });
|
||||
}
|
||||
const sourceDigest = args.at(-1)!.split("@")[1]!;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] === "--tag") {
|
||||
targetDigests.set(args[index + 1]!, sourceDigest);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
const docker = createDockerMock({
|
||||
candidateVersion: "2026.6.33",
|
||||
currentVersion: "2026.6.33",
|
||||
});
|
||||
const execFileSyncImpl = vi.fn((command: string, args: string[]) => {
|
||||
calls.push(args);
|
||||
return docker(command, args);
|
||||
});
|
||||
const verifyAttestationsImpl = vi.fn();
|
||||
|
||||
promoteDockerChannel({ version: "2026.6.33", images }, { execFileSyncImpl });
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl },
|
||||
);
|
||||
|
||||
const firstCreate = calls.findIndex((args) => args[2] === "create");
|
||||
expect(firstCreate).toBe(6);
|
||||
expect(firstCreate).toBe(30);
|
||||
expect(calls.slice(0, firstCreate).every((args) => args[2] === "inspect")).toBe(true);
|
||||
expect(calls.filter((args) => args[2] === "create")).toHaveLength(6);
|
||||
expect(verifyAttestationsImpl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
imageRefs: [
|
||||
`ghcr.io/openclaw/openclaw@${digest}`,
|
||||
`ghcr.io/openclaw/openclaw@${digest}`,
|
||||
`ghcr.io/openclaw/openclaw@${digest}`,
|
||||
`docker.io/openclaw/openclaw@${digest}`,
|
||||
`docker.io/openclaw/openclaw@${digest}`,
|
||||
`docker.io/openclaw/openclaw@${digest}`,
|
||||
],
|
||||
requiredPlatforms: [
|
||||
{ architecture: "amd64", os: "linux", variant: undefined },
|
||||
{ architecture: "arm64", os: "linux", variant: undefined },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(execFileSyncImpl).toHaveBeenCalledWith(
|
||||
"docker",
|
||||
[
|
||||
@@ -106,7 +156,7 @@ describe("Docker channel promotion", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fails without mutating when any immutable source is missing", () => {
|
||||
it("fails without mutating when any version-specific source is missing", () => {
|
||||
const calls: string[][] = [];
|
||||
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
|
||||
calls.push(args);
|
||||
@@ -117,23 +167,196 @@ describe("Docker channel promotion", () => {
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
promoteDockerChannel({ version: "2026.6.33", images }, { execFileSyncImpl }),
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification },
|
||||
),
|
||||
).toThrow("missing manifest");
|
||||
expect(calls.some((args) => args[2] === "create")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when a promoted alias does not match its immutable source", () => {
|
||||
const wrongDigest = `sha256:${"2".repeat(64)}`;
|
||||
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
|
||||
if (args[2] === "inspect" && args[3]?.endsWith(":extended-stable")) {
|
||||
return JSON.stringify({ digest: wrongDigest });
|
||||
}
|
||||
return args[2] === "inspect" ? JSON.stringify({ digest }) : "";
|
||||
it("fails when a promoted alias does not match its version-specific source", () => {
|
||||
const execFileSyncImpl = createDockerMock({
|
||||
candidateVersion: "2026.6.33",
|
||||
currentVersion: "2026.6.33",
|
||||
wrongTargetDigest: changedDigest,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
promoteDockerChannel({ version: "2026.6.33", images }, { execFileSyncImpl }),
|
||||
).toThrow(`resolved to ${wrongDigest}, expected ${digest}`);
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification },
|
||||
),
|
||||
).toThrow(`resolved to ${changedDigest}, expected ${digest}`);
|
||||
});
|
||||
|
||||
it("refuses automatic channel rollback before writing aliases", () => {
|
||||
const execFileSyncImpl = createDockerMock({
|
||||
candidateVersion: "2026.6.33",
|
||||
currentVersion: "2026.6.34",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images: images.slice(0, 1) },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification },
|
||||
),
|
||||
).toThrow(
|
||||
"Refusing to move ghcr.io/openclaw/openclaw:extended-stable backward from 2026.6.34 to 2026.6.33",
|
||||
);
|
||||
expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["same", "2026.6.33", "2026.6.33"],
|
||||
["newer", "2026.6.34", "2026.6.33"],
|
||||
])("allows an automatic %s-version promotion", (_label, candidateVersion, currentVersion) => {
|
||||
const execFileSyncImpl = createDockerMock({ candidateVersion, currentVersion });
|
||||
|
||||
promoteDockerChannel(
|
||||
{ version: candidateVersion, images: images.slice(0, 1) },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification },
|
||||
);
|
||||
|
||||
expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an explicitly approved rollback", () => {
|
||||
const execFileSyncImpl = createDockerMock({
|
||||
candidateVersion: "2026.6.33",
|
||||
currentVersion: "2026.6.34",
|
||||
});
|
||||
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images: images.slice(0, 1) },
|
||||
{
|
||||
allowRollback: true,
|
||||
execFileSyncImpl,
|
||||
verifyAttestationsImpl: skipAttestationVerification,
|
||||
},
|
||||
);
|
||||
|
||||
expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a first promotion when the target alias does not exist", () => {
|
||||
let created = false;
|
||||
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
|
||||
if (args[2] === "create") {
|
||||
created = true;
|
||||
return "";
|
||||
}
|
||||
if (args.at(-1)?.includes(".Image")) {
|
||||
if (!args[3]!.includes("@") && !created) {
|
||||
const error = new Error("docker inspect failed");
|
||||
Object.assign(error, { stderr: `ERROR: ${args[3]}: not found` });
|
||||
throw error;
|
||||
}
|
||||
return imageConfig("2026.6.33");
|
||||
}
|
||||
return JSON.stringify({ digest });
|
||||
});
|
||||
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images: images.slice(0, 1) },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification },
|
||||
);
|
||||
|
||||
expect(created).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when an existing alias cannot be inspected", () => {
|
||||
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
|
||||
if (args.at(-1)?.includes(".Image") && !args[3]!.includes("@")) {
|
||||
const error = new Error("unauthorized: authentication required");
|
||||
Object.assign(error, { stderr: "denied: requested access to the resource is denied" });
|
||||
throw error;
|
||||
}
|
||||
if (args.at(-1)?.includes(".Image")) {
|
||||
return imageConfig("2026.6.33");
|
||||
}
|
||||
return JSON.stringify({ digest });
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images: images.slice(0, 1) },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification },
|
||||
),
|
||||
).toThrow("unauthorized");
|
||||
expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false);
|
||||
});
|
||||
|
||||
it("promotes the same digests whose attestations were verified", () => {
|
||||
let sourceDigest = digest;
|
||||
const targetDigests = new Map<string, string>();
|
||||
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
|
||||
if (args[2] === "create") {
|
||||
const promotedDigest = args.at(-1)!.split("@")[1]!;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] === "--tag") {
|
||||
targetDigests.set(args[index + 1]!, promotedDigest);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (args.at(-1)?.includes(".Image")) {
|
||||
return imageConfig("2026.6.33");
|
||||
}
|
||||
const ref = args[3]!;
|
||||
return JSON.stringify({ digest: targetDigests.get(ref) ?? sourceDigest });
|
||||
});
|
||||
const verifiedRefs: string[] = [];
|
||||
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images: images.slice(0, 1) },
|
||||
{
|
||||
execFileSyncImpl,
|
||||
verifyAttestationsImpl({ imageRefs }) {
|
||||
verifiedRefs.push(...imageRefs);
|
||||
sourceDigest = changedDigest;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(verifiedRefs).toEqual(Array(3).fill(`ghcr.io/openclaw/openclaw@${digest}`));
|
||||
expect(
|
||||
execFileSyncImpl.mock.calls
|
||||
.filter(([, args]) => args[2] === "create")
|
||||
.map(([, args]) => args.at(-1)),
|
||||
).toEqual(Array(3).fill(`ghcr.io/openclaw/openclaw@${digest}`));
|
||||
});
|
||||
|
||||
it("rejects a source whose version label does not match the requested release", () => {
|
||||
const execFileSyncImpl = createDockerMock({
|
||||
candidateVersion: "2026.6.34",
|
||||
currentVersion: "2026.6.33",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images: images.slice(0, 1) },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification },
|
||||
),
|
||||
).toThrow(`ghcr.io/openclaw/openclaw@${digest} reports version 2026.6.34, expected 2026.6.33`);
|
||||
});
|
||||
|
||||
it("rejects a source whose platform version labels disagree", () => {
|
||||
const execFileSyncImpl = vi.fn((_command: string, args: string[]) => {
|
||||
if (args.at(-1)?.includes(".Image")) {
|
||||
const version = args.at(-1)?.includes("linux/arm64") ? "2026.6.34" : "2026.6.33";
|
||||
return imageConfig(version);
|
||||
}
|
||||
return JSON.stringify({ digest });
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
promoteDockerChannel(
|
||||
{ version: "2026.6.33", images: images.slice(0, 1) },
|
||||
{ execFileSyncImpl, verifyAttestationsImpl: skipAttestationVerification },
|
||||
),
|
||||
).toThrow("inconsistent platform versions: linux/amd64=2026.6.33, linux/arm64=2026.6.34");
|
||||
expect(execFileSyncImpl.mock.calls.some(([, args]) => args[2] === "create")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects channels without moving aliases", () => {
|
||||
@@ -142,7 +365,7 @@ describe("Docker channel promotion", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same attestation-gated promotion path for releases and repairs", () => {
|
||||
it("uses the digest-bound promotion path for releases and approved repairs", () => {
|
||||
const workflow = readWorkflow(".github/workflows/docker-channel-promote.yml");
|
||||
const releaseWorkflow = readWorkflow(".github/workflows/docker-release.yml");
|
||||
const createManifest = requireJob(releaseWorkflow, "create-manifest");
|
||||
@@ -183,6 +406,7 @@ describe("Docker channel promotion", () => {
|
||||
expect(releaseSteps[releasePromotionIndex]?.run).toContain(
|
||||
"node scripts/docker-channel-promote.mjs",
|
||||
);
|
||||
expect(releaseSteps[releasePromotionIndex]?.run).not.toContain("--allow-rollback");
|
||||
expect(
|
||||
Object.values(releaseWorkflow.jobs ?? {}).flatMap((job) =>
|
||||
(job.steps ?? []).filter((step) => step.run?.includes("docker-channel-promote.mjs")),
|
||||
@@ -208,30 +432,13 @@ describe("Docker channel promotion", () => {
|
||||
);
|
||||
|
||||
const steps = promote.steps ?? [];
|
||||
const attestationIndex = steps.findIndex(
|
||||
(step) => step.name === "Verify immutable source attestations",
|
||||
);
|
||||
const promotionIndex = steps.findIndex(
|
||||
(step) => step.name === "Promote and verify channel aliases",
|
||||
);
|
||||
expect(attestationIndex).toBeGreaterThan(-1);
|
||||
expect(promotionIndex).toBeGreaterThan(attestationIndex);
|
||||
|
||||
const attestationRun = steps[attestationIndex]?.run ?? "";
|
||||
for (const ref of [
|
||||
"${GHCR_IMAGE}:${VERSION}",
|
||||
"${GHCR_IMAGE}:${VERSION}-slim",
|
||||
"${GHCR_IMAGE}:${VERSION}-browser",
|
||||
"${DOCKERHUB_IMAGE}:${VERSION}",
|
||||
"${DOCKERHUB_IMAGE}:${VERSION}-slim",
|
||||
"${DOCKERHUB_IMAGE}:${VERSION}-browser",
|
||||
]) {
|
||||
expect(attestationRun).toContain(ref);
|
||||
}
|
||||
expect(attestationRun).toContain("node scripts/verify-docker-attestations.mjs");
|
||||
expect(attestationRun).toContain("--platform linux/amd64");
|
||||
expect(attestationRun).toContain("--platform linux/arm64");
|
||||
expect(steps.some((step) => step.run?.includes("verify-docker-attestations.mjs"))).toBe(false);
|
||||
expect(promotionIndex).toBeGreaterThan(-1);
|
||||
expect(steps[promotionIndex]?.run).toContain("node scripts/docker-channel-promote.mjs");
|
||||
expect(steps[promotionIndex]?.run).toContain("--allow-rollback");
|
||||
|
||||
const packageWriters = Object.entries(workflow.jobs ?? {}).filter(
|
||||
([, job]) => job.permissions?.packages === "write",
|
||||
|
||||
Reference in New Issue
Block a user