mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 07:37:36 +09:00
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.
186 lines
5.7 KiB
TypeScript
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,
|
|
});
|
|
}
|