Files
webstudio/packages/cli/scripts/builder-contract-gate.ts
Oleg Isonen bc8630f413 fix: Gate Builder releases on the published CLI contract (#6282)
Closes #6280

## Summary

- Expose the deployed Builder's CLI-facing bundle contract through a
non-cached, read-only endpoint.
- Resolve and execute the exact `webstudio@latest` registry artifact,
observe the bundle version sent by its real `sync` request, and require
an exact match before assigning the release staging alias.
- Include expected and received bundle versions in runtime and
release-gate errors.

## Technical choices

- The existing generated `bundleVersion` remains the single
compatibility boundary. Builder-only changes outside that project-bundle
contract do not require a CLI release.
- The CLI sync probe runs against a local fixture API and temporary
directory. It reads the deployed runtime contract but never reads or
modifies a Cloud project.
- Verification runs after Vercel creates the exact production-built
deployment and before the release staging alias is assigned. A mismatch
therefore leaves production domains untouched.
- Registry resolution requires version and integrity metadata, then
executes the resolved immutable version rather than source from the
checkout.
- The gate requires the deployed Builder origin and cannot fall back to
the checkout's contract.

## Todo

1. [x] Add the deployed Builder contract endpoint.
2. [x] Probe the exact published CLI through its real sync request.
3. [x] Gate the release staging alias on an exact contract match.
4. [x] Report expected and received versions for compatibility failures.
5. [x] Add regression tests and verify the production Builder build.
6. [x] Review documentation and fixture impact.

## Verification

- `pnpm --filter @webstudio-is/builder exec vitest run
app/services/build-router.server.test.ts
app/services/cli-compatibility-route.node.test.ts` — 12 passed.
- `pnpm --filter webstudio exec vitest run
scripts/builder-contract-gate.test.ts
scripts/release-smoke-registry.test.ts` — 7 passed.
- Rebased onto `main` at `9ec4c820e1`.
- Builder and CLI typechecks passed.
- `pnpm lint` and changed-file formatting passed.
- `pnpm --filter @webstudio-is/builder build` passed and emitted
`/rest/cli-compatibility`.
- The changed GitHub Action parses as valid YAML.
- Real registry probe passed for `webstudio@0.293.0` against
`bundle-kzb45v`.
- Real registry probe correctly blocked current Builder contract
`bundle-1mym82p` and reported expected `bundle-1mym82p`, received
`bundle-kzb45v`.
- Missing staged Builder origin fails closed before registry execution.
- GitHub CI passed all four workflows, including all six Builder E2E
shards.
- The formerly failing empty-MDX insertion workflow passed 8 repeated
runs with 4 workers locally, then passed in Builder E2E shard 4 in CI.
- Full browser-based release smoke was not run, per the request not to
use Chrome. Agent evaluations were not run because no evaluation
approval was requested or received.
- Documentation: no user-facing documentation changes are required.
- Fixtures/generated outputs: no fixture inputs or generated outputs are
affected.

## Operational boundary

The check needs the exact deployed runtime, so Vercel creates an
unaliased `--skip-domain` deployment before verification. A failed check
cannot affect production domains or the normal release alias. Repository
automation cannot prevent a Vercel administrator from manually promoting
an arbitrary deployment outside this release path.
2026-08-31 20:43:52 +02:00

186 lines
5.7 KiB
TypeScript

import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { getBundleVersion } from "@webstudio-is/protocol";
import { createPublishedProjectBundleFixture } from "@webstudio-is/protocol/fixtures";
import { resolveRegistryTarget } from "./release-smoke-registry";
import { startRuntimeFixtureApi } from "./runtime-fixture-api";
const execFileAsync = promisify(execFile);
export const assertPublishedCliContract = ({
cliVersion,
expectedVersion,
receivedVersion,
}: {
cliVersion: string;
expectedVersion: string | number;
receivedVersion: string | number | undefined;
}) => {
if (receivedVersion === expectedVersion) {
return;
}
throw new Error(
`Published CLI contract mismatch for webstudio@${cliVersion}. Expected bundle version ${expectedVersion}, received ${receivedVersion ?? "missing"}. Publish a matching CLI before deploying Builder.`
);
};
const readNpmJson = async (args: string[]) =>
JSON.parse((await execFileAsync("npm", args)).stdout) as unknown;
const contractEndpointPath = "/rest/cli-compatibility";
const defaultDelay = () =>
new Promise<void>((resolveDelay) => setTimeout(resolveDelay, 3_000));
export const loadBuilderBundleVersion = async (
origin: string,
{
attempts = 10,
delay = defaultDelay,
request = fetch,
}: {
attempts?: number;
delay?: () => Promise<void>;
request?: typeof fetch;
} = {}
) => {
const endpoint = new URL(contractEndpointPath, origin);
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await request(endpoint, {
cache: "no-store",
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (response.ok === false) {
throw new Error(
`Builder CLI compatibility endpoint returned HTTP ${response.status}.`
);
}
const data = (await response.json()) as unknown;
const receivedVersion = getBundleVersion(data);
if (receivedVersion === undefined) {
throw new Error(
"Builder CLI compatibility response has no bundle version."
);
}
return receivedVersion;
} catch (error) {
lastError = error;
if (attempt < attempts) {
await delay();
}
}
}
const message =
lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(
`Could not read the CLI contract from ${endpoint.href} after ${attempts} ${attempts === 1 ? "attempt" : "attempts"}: ${message}`,
{ cause: lastError }
);
};
const checkPublishedCliContract = async ({
builderOrigin,
}: {
builderOrigin: string;
}) => {
const expectedVersion = await loadBuilderBundleVersion(builderOrigin);
const target = await resolveRegistryTarget("webstudio@latest", {
attempts: 3,
readNpmJson,
});
const directory = await mkdtemp(join(tmpdir(), "webstudio-contract-gate-"));
let receivedVersion: string | number | undefined;
let fixtureOrigin = "";
try {
const fixtureApi = await startRuntimeFixtureApi(
async ({ operationPath, readInput }) => {
if (operationPath !== "build.loadProjectBundleByBuildId") {
throw new Error(`Unexpected CLI probe operation ${operationPath}.`);
}
receivedVersion = getBundleVersion(await readInput());
return createPublishedProjectBundleFixture({ origin: fixtureOrigin });
}
);
fixtureOrigin = fixtureApi.origin;
try {
await mkdir(join(directory, ".webstudio"), { recursive: true });
let probeError: unknown;
try {
await execFileAsync(
"npm",
[
"exec",
"--yes",
"--package",
target.installSpec,
"--",
"webstudio",
"sync",
"--buildId",
"contract-probe",
"--origin",
fixtureApi.origin,
"--authToken",
"contract-probe",
],
{
cwd: directory,
maxBuffer: 20 * 1024 * 1024,
timeout: 5 * 60_000,
}
);
} catch (error) {
probeError = error;
}
assertPublishedCliContract({
cliVersion: target.version,
expectedVersion,
receivedVersion,
});
if (probeError !== undefined) {
const stderr =
typeof probeError === "object" &&
probeError !== null &&
"stderr" in probeError &&
typeof probeError.stderr === "string"
? probeError.stderr.trim()
: "";
throw new Error(
`Published CLI webstudio@${target.version} sent the expected bundle version ${expectedVersion} but could not complete the read-only sync probe: ${stderr || (probeError instanceof Error ? probeError.message : String(probeError))}`,
{ cause: probeError }
);
}
console.info(
`Published CLI webstudio@${target.version} matches Builder bundle version ${expectedVersion}.`
);
} finally {
await fixtureApi.close();
}
} finally {
await rm(directory, { force: true, recursive: true });
}
};
const entrypoint = process.argv[1];
if (
entrypoint !== undefined &&
import.meta.url === pathToFileURL(resolve(entrypoint)).href
) {
const builderOrigin = process.env.WEBSTUDIO_CLI_CONTRACT_BUILDER_ORIGIN;
if (builderOrigin === undefined || builderOrigin === "") {
throw new Error(
"WEBSTUDIO_CLI_CONTRACT_BUILDER_ORIGIN must identify the staged Builder deployment."
);
}
await checkPublishedCliContract({
builderOrigin,
});
}