Files
open-design/tools/release/tests/rollback-latest.test.ts
54139504d4 feat(release): add one-dispatch stable rollback (#8507)
* feat(release): add one-dispatch stable rollback

A stable release publishes its artifacts to an immutable
stable/versions/<version> prefix and then copies the pointer objects into
stable/latest. latest is therefore derived state, and retracting a release is
copying an older version prefix over it again — but until now nothing in the
repo could do that, so a bad stable release had no stop button.

Add `tools-release rollback-latest` plus a release-rollback workflow that
dispatches it. The CLI refuses unless the live pointer still serves the version
the operator typed as from_version, the target published completely (not
partial, not a dry run), every ready platform's manifest and updater feed exist
in the target prefix, and the current installer floor is satisfiable by the
target. It re-resolves control.launcher.version from live channel policy rather
than restoring the snapshot's, records rollback provenance, writes metadata.json
last so latest never advertises a version whose feeds it does not yet serve, and
verifies every object through both the origin and the public URL.

The workflow shares release-stable's publish concurrency group so a rollback
cannot overlap a publish, moves only the GitHub Release "Latest" badge (never a
release body), and ends with a checklist of what a rollback does not reach: the
download page's version-pinned links, the What's New card, the group
announcement, and above all the installs already on the retracted version —
the updater never offers a version that is not strictly newer than the running
one, so this stops a rollout and cannot recall one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(release): group the rollback summary fallback redirects

shellcheck SC2129 — CI runs actionlint with shellcheck wired in, which my
local actionlint could not see without it installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(release): prove the restored tag has a release before writing

The badge move runs after the R2 write, so a missing GitHub Release for the
restored version would have failed the run with stable/latest already moved.
Check it up front instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(release): pass the stable installer-floor policy into rollback

resolveLauncherVersionFloor() reads process env, and repository variables are
not process env. Without the handoff a rollback resolved no floor at all: it
would strip an active control.launcher.version from the restored metadata and
stop refusing targets below the current minimum — the safeguard the workflow
documents as enforced. Latent today only because no stable floor is set.

release-stable.yml passes the same pair to metadata publish and verify; mirror
it, with a topology assertion so a later edit cannot drop it silently. Also
stamp RELEASE_BRANCH/RELEASE_COMMIT so the rollback provenance is not blank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(release): require the restored release to be badge-eligible

gh release view succeeds for a draft or a prerelease, but GitHub will not put
the Latest badge on either — so the preflight passed and the edit failed with
the R2 rollback already done, which is the half-complete state the preflight
exists to prevent. Check isDraft and isPrerelease too, and cover the
eligibility guard in the workflow test alongside its ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 03:21:12 +00:00

320 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const storageMocks = vi.hoisted(() => ({
getStorageObject: vi.fn(),
putStorageObject: vi.fn(),
}));
vi.mock("../src/storage/s3-upload.ts", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/storage/s3-upload.ts")>()),
getStorageObject: storageMocks.getStorageObject,
putStorageObject: storageMocks.putStorageObject,
}));
import {
applyLatestRollback,
planLatestRollback,
verifyLatestRollback,
} from "../src/storage/rollback-latest.ts";
import type { StorageConfig } from "../src/storage/s3-upload.ts";
const storage: StorageConfig = {
accessKeyId: "ak",
bucket: "releases",
endpointUrl: "https://storage.example.test",
region: "auto",
secretAccessKey: "sk",
};
type Manifest = {
channel: string;
feed: { name: string } | null;
platformKey: string;
releaseVersion: string;
status: string;
};
function versionMetadata(version: string, targets: string[], overrides: Record<string, unknown> = {}) {
return {
channel: "stable",
generatedAt: "2026-09-24T09:48:47.421Z",
platforms: {},
r2: { versionPrefix: `stable/versions/${version}` },
readyTargets: targets,
releaseState: "complete",
releaseVersion: version,
...overrides,
};
}
function manifest(version: string, target: string, feedName: string | null, overrides: Partial<Manifest> = {}) {
return {
channel: "stable",
feed: feedName == null ? null : { name: feedName },
platformKey: target,
releaseVersion: version,
status: "published",
...overrides,
};
}
/** A published 0.24.1 plus a live latest pointing at 0.25.0, as storage would hold them. */
function defaultObjects(): Record<string, string> {
return {
"stable/latest/metadata.json": JSON.stringify(versionMetadata("0.25.0", ["mac_arm64", "win_x64"])),
"stable/versions/0.24.1/metadata.json": JSON.stringify(versionMetadata("0.24.1", ["mac_arm64", "win_x64"])),
"stable/versions/0.24.1/platforms/mac_arm64.json": JSON.stringify(manifest("0.24.1", "mac_arm64", "latest-mac.yml")),
"stable/versions/0.24.1/platforms/win_x64.json": JSON.stringify(manifest("0.24.1", "win_x64", "latest.yml")),
"stable/versions/0.24.1/latest-mac.yml": "version: 0.24.1\n",
"stable/versions/0.24.1/latest.yml": "version: 0.24.1\n",
};
}
let objects: Record<string, string>;
/** Back both mocks with the same in-memory bucket so writes are readable again. */
function serve(): void {
storageMocks.getStorageObject.mockImplementation(async ({ objectKey }: { objectKey: string }) => {
const text = objects[objectKey];
if (text == null) return null;
const bytes = Buffer.from(text, "utf8");
return { bytes, etag: `"etag-${objectKey}"`, text };
});
storageMocks.putStorageObject.mockImplementation(async ({ body, objectKey }: { body: Buffer; objectKey: string }) => {
objects[objectKey] = Buffer.from(body).toString("utf8");
});
}
const FLOOR_VARS = ["RELEASE_LAUNCHER_VERSION_MIN_STABLE", "RELEASE_LAUNCHER_VERSION_MIN_URL_STABLE"];
describe("stable latest rollback", () => {
beforeEach(() => {
storageMocks.getStorageObject.mockReset();
storageMocks.putStorageObject.mockReset();
objects = defaultObjects();
for (const name of FLOOR_VARS) delete process.env[name];
serve();
});
afterEach(() => {
for (const name of FLOOR_VARS) delete process.env[name];
});
it("[P0] restores every latest object from the target version prefix", async () => {
const plan = await planLatestRollback(storage, {
channel: "stable",
fromVersion: "0.25.0",
toVersion: "0.24.1",
});
expect(plan.objects.map((object) => object.targetKey)).toEqual([
"stable/latest/platforms/mac_arm64.json",
"stable/latest/latest-mac.yml",
"stable/latest/platforms/win_x64.json",
"stable/latest/latest.yml",
"stable/latest/metadata.json",
]);
expect(plan.fromVersion).toBe("0.25.0");
expect(plan.versionPrefix).toBe("stable/versions/0.24.1");
});
it("[P0] writes metadata.json last so latest never advertises a version it does not serve", async () => {
const plan = await planLatestRollback(storage, {
channel: "stable",
fromVersion: "0.25.0",
toVersion: "0.24.1",
});
await applyLatestRollback(storage, plan);
const written = storageMocks.putStorageObject.mock.calls.map(([call]) => call.objectKey as string);
expect(written.at(-1)).toBe("stable/latest/metadata.json");
expect(written).toHaveLength(plan.objects.length);
for (const [call] of storageMocks.putStorageObject.mock.calls) {
expect(call.cacheControl).toBe("public, max-age=60, must-revalidate");
}
});
it("[P0] republishes the target version as the served release version", async () => {
const plan = await planLatestRollback(storage, {
channel: "stable",
fromVersion: "0.25.0",
github: { runId: 42 },
now: new Date("2026-09-29T02:00:00.000Z"),
toVersion: "0.24.1",
});
const metadata = JSON.parse(plan.objects.at(-1)!.bytes.toString("utf8"));
expect(metadata.releaseVersion).toBe("0.24.1");
expect(metadata.releaseState).toBe("complete");
expect(metadata.rollback).toEqual({
at: "2026-09-29T02:00:00.000Z",
from: "0.25.0",
github: { runId: 42 },
to: "0.24.1",
});
});
it("[P0] refuses when latest no longer serves the version the operator meant to retract", async () => {
objects["stable/latest/metadata.json"] = JSON.stringify(versionMetadata("0.25.1", ["mac_arm64", "win_x64"]));
await expect(
planLatestRollback(storage, { channel: "stable", fromVersion: "0.25.0", toVersion: "0.24.1" }),
).rejects.toThrow(/currently serves 0\.25\.1, not the expected 0\.25\.0/);
expect(storageMocks.putStorageObject).not.toHaveBeenCalled();
});
it("[P0] refuses to move the channel forward unless that is explicitly requested", async () => {
objects["stable/versions/0.26.0/metadata.json"] = JSON.stringify(versionMetadata("0.26.0", ["mac_arm64"]));
objects["stable/versions/0.26.0/platforms/mac_arm64.json"] = JSON.stringify(
manifest("0.26.0", "mac_arm64", "latest-mac.yml"),
);
objects["stable/versions/0.26.0/latest-mac.yml"] = "version: 0.26.0\n";
await expect(
planLatestRollback(storage, { channel: "stable", fromVersion: "0.25.0", toVersion: "0.26.0" }),
).rejects.toThrow(/newer than the live 0\.25\.0/);
const forward = await planLatestRollback(storage, {
allowForward: true,
channel: "stable",
fromVersion: "0.25.0",
toVersion: "0.26.0",
});
expect(forward.toVersion).toBe("0.26.0");
});
it("[P0] refuses a target version that never completed publication", async () => {
objects["stable/versions/0.24.1/metadata.json"] = JSON.stringify(
versionMetadata("0.24.1", ["mac_arm64", "win_x64"], { releaseState: "partial" }),
);
await expect(
planLatestRollback(storage, { channel: "stable", fromVersion: "0.25.0", toVersion: "0.24.1" }),
).rejects.toThrow(/releaseState partial/);
});
it("[P0] refuses a target version written by a dry run", async () => {
objects["stable/versions/0.24.1/metadata.json"] = JSON.stringify(
versionMetadata("0.24.1", ["mac_arm64", "win_x64"], { dryRun: true }),
);
await expect(
planLatestRollback(storage, { channel: "stable", fromVersion: "0.25.0", toVersion: "0.24.1" }),
).rejects.toThrow(/dry run/);
});
it("[P0] refuses when a ready platform's manifest or updater feed is missing", async () => {
delete objects["stable/versions/0.24.1/latest.yml"];
await expect(
planLatestRollback(storage, { channel: "stable", fromVersion: "0.25.0", toVersion: "0.24.1" }),
).rejects.toThrow(/missing updater feed stable\/versions\/0\.24\.1\/latest\.yml/);
objects = defaultObjects();
delete objects["stable/versions/0.24.1/platforms/win_x64.json"];
await expect(
planLatestRollback(storage, { channel: "stable", fromVersion: "0.25.0", toVersion: "0.24.1" }),
).rejects.toThrow(/missing stable\/versions\/0\.24\.1\/platforms\/win_x64\.json/);
});
it("[P0] refuses a version with no published metadata at all", async () => {
await expect(
planLatestRollback(storage, { channel: "stable", fromVersion: "0.25.0", toVersion: "0.20.0" }),
).rejects.toThrow(/no published stable release metadata for 0\.20\.0/);
});
it("[P1] resolves the signed version prefix when the plain one was never published", async () => {
objects = {
"stable/latest/metadata.json": JSON.stringify(versionMetadata("0.25.0", ["mac_arm64"])),
"stable/versions/0.24.1.signed/metadata.json": JSON.stringify(
versionMetadata("0.24.1", ["mac_arm64"], { r2: { versionPrefix: "stable/versions/0.24.1.signed" } }),
),
"stable/versions/0.24.1.signed/platforms/mac_arm64.json": JSON.stringify(
manifest("0.24.1", "mac_arm64", "latest-mac.yml"),
),
"stable/versions/0.24.1.signed/latest-mac.yml": "version: 0.24.1\n",
};
const plan = await planLatestRollback(storage, {
channel: "stable",
fromVersion: "0.25.0",
toVersion: "0.24.1",
});
expect(plan.versionPrefix).toBe("stable/versions/0.24.1.signed");
});
it("[P1] collapses two platforms that share one updater feed into a single write", async () => {
objects["stable/versions/0.24.1/metadata.json"] = JSON.stringify(
versionMetadata("0.24.1", ["mac_arm64", "mac_x64"]),
);
objects["stable/versions/0.24.1/platforms/mac_x64.json"] = JSON.stringify(
manifest("0.24.1", "mac_x64", "latest-mac.yml"),
);
const plan = await planLatestRollback(storage, {
channel: "stable",
fromVersion: "0.25.0",
toVersion: "0.24.1",
});
expect(plan.objects.filter((object) => object.targetKey === "stable/latest/latest-mac.yml")).toHaveLength(1);
});
it("[P0] republishes the current installer floor policy rather than the snapshot's", async () => {
objects["stable/versions/0.24.1/metadata.json"] = JSON.stringify(
versionMetadata("0.24.1", ["mac_arm64", "win_x64"], {
control: { launcher: { version: { min: "0.19.0" } } },
}),
);
process.env.RELEASE_LAUNCHER_VERSION_MIN_STABLE = "0.22.0";
const plan = await planLatestRollback(storage, {
channel: "stable",
fromVersion: "0.25.0",
toVersion: "0.24.1",
});
const metadata = JSON.parse(plan.objects.at(-1)!.bytes.toString("utf8"));
expect(metadata.control).toEqual({ launcher: { version: { min: "0.22.0" } } });
});
it("[P0] drops a stale control block when the channel no longer sets a floor", async () => {
objects["stable/versions/0.24.1/metadata.json"] = JSON.stringify(
versionMetadata("0.24.1", ["mac_arm64", "win_x64"], {
control: { launcher: { version: { min: "0.19.0" } } },
}),
);
const plan = await planLatestRollback(storage, {
channel: "stable",
fromVersion: "0.25.0",
toVersion: "0.24.1",
});
const metadata = JSON.parse(plan.objects.at(-1)!.bytes.toString("utf8"));
expect(metadata.control).toBeUndefined();
});
it("[P0] refuses a rollback below the channel's installer floor", async () => {
process.env.RELEASE_LAUNCHER_VERSION_MIN_STABLE = "0.25.0";
await expect(
planLatestRollback(storage, { channel: "stable", fromVersion: "0.25.0", toVersion: "0.24.1" }),
).rejects.toThrow(/launcher version floor 0\.25\.0 exceeds release version 0\.24\.1/);
});
it("[P0] fails verification when a rolled back object does not match what was written", async () => {
const plan = await planLatestRollback(storage, {
channel: "stable",
fromVersion: "0.25.0",
toVersion: "0.24.1",
});
await applyLatestRollback(storage, plan);
await expect(verifyLatestRollback(storage, plan)).resolves.toBeUndefined();
objects["stable/latest/latest.yml"] = "version: 0.25.0\n";
await expect(verifyLatestRollback(storage, plan)).rejects.toThrow(
/does not match what was written: stable\/latest\/latest\.yml/,
);
});
});