Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs/domain-cutover.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The authenticated Vercel alias readback is the traffic authority. Its exact `(pr
vercel api /v4/aliases/hra.sh --scope hraness --raw | jq -c '{alias,projectId,deploymentId,deployment:{id:.deployment.id,url:.deployment.url}}'
```

`https://hra.sh/.well-known/hra.json` is independent product evidence. Generation 0 must identify repository ID `1334876494`, path `hraness/hra-v0`, and the exact accepted archive source commit. Generation 1 must identify repository ID `1343008607`, path `hraness/hra`, and the exact accepted new-HRA source commit. Both markers carry `source.commit` and version. A marker does not replace the deployment-ID readback because two deployments can share source and version.
`https://hra.sh/.well-known/hra.json` is independent product evidence. Generation 0 must identify repository ID `1334876494`, path `hraness/hra-v0`, the exact accepted archive source commit, and the archive version at `publication.version`. Generation 1 must identify repository ID `1343008607`, path `hraness/hra`, the exact accepted new-HRA source commit, and its top-level `version`. Both schema-version-2 markers carry `source.commit`; their generation-discriminated version locations are intentional. A marker does not replace the deployment-ID readback because two deployments can share source and version.

Record only filtered provider fields. Full deployment and alias responses can contain operator identity data. Do not use `--debug`, `--verbose`, `--token`, `--force`, remove-then-add, or a token-bearing shell variable.

Expand Down Expand Up @@ -53,21 +53,25 @@ vercel api /v13/deployments/<deployment-id> --scope hraness --raw | jq -c '{id,u

Require `readyState` to be `READY`, `gitSource.type` to be `github`, `gitSource.ref` to be `main`, and the project ID, repository ID, source commit, deployment ID, and bare automatic hostname to match the accepted release. A bare automatic hostname already ends in `.vercel.app`; never append that suffix again. The deployment URL is an exact provider identity and alias destination, not proof that an unauthenticated browser can reach it. Deployment protection can cover automatic deployment URLs.

Stage every production deployment without automatic alias promotion, even after disabling the project setting:
Stage a source upload without automatic alias promotion, even after disabling the project setting:

```sh
vercel deploy <project-path> --prod --skip-domain --project <fixed-project-id> --scope hraness
```

Do not accept a Q or N deployment created by a command that omitted `--skip-domain`. Git-created deployments remain safe only while the exact numeric-project readbacks above stay `false`.
Do not accept a source-uploaded Q or N deployment created by a command that omitted `--skip-domain`. A deployment created from Vercel's immutable Git source may instead be rebuilt with `vercel redeploy <exact-git-deployment-id> --target production` when the original build failed before release, but only after independently proving its exact `gitSource` tuple and reading `autoAssignCustomDomains === false` immediately before and after the rebuild. `vercel redeploy` has no `--skip-domain` option. The resulting deployment remains acceptable only when it preserves the exact GitHub repository ID, `main` ref, source commit, project ID, and disabled custom-domain setting. A CLI source upload whose deployment record has `gitSource: null` is not a Q or N candidate even when its Git metadata strings look correct.

Inspect Q and N before exposing them through a public custom alias with the authenticated local Vercel session:

From the fixed, mode-`0700` linked operator directory whose `.vercel/project.json` names the exact numeric project, run:

```sh
vercel curl / --deployment <deployment-id> --scope hraness
vercel curl /.well-known/hra.json --deployment <deployment-id> --scope hraness
vercel curl / --deployment <deployment-id>
vercel curl /.well-known/hra.json --deployment <deployment-id>
```

Vercel CLI `54.18.0` forwards `--scope` to its nested curl process even when written as a global option, so do not add it to `vercel curl`. The protected fixed link supplies project and team identity; the separate deployment and project API readbacks remain authoritative.

Use `vercel curl --deployment` for every release-specific path needed by acceptance. Do not pass, print, save, or script a protection-bypass secret. These authenticated checks do not replace the later public custom-alias probes.

Prepare these exact endpoints:
Expand Down
90 changes: 79 additions & 11 deletions scripts/domain-cutover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,23 +112,40 @@ const projectFor = (
id: projectId,
});

const markerFor = (endpoint: CutoverEndpoint): unknown => ({
generation: endpoint.generation,
product: "HRA",
repository: {
id: endpoint.repositoryId,
path: endpoint.projectId === oldProjectId ? "hraness/hra-v0" : "hraness/hra",
},
schemaVersion: 2,
source: { commit: endpoint.sourceCommit },
version: endpoint.version,
});
const markerFor = (endpoint: CutoverEndpoint): unknown => {
const shared = {
generation: endpoint.generation,
product: "HRA",
repository: {
id: endpoint.repositoryId,
path: endpoint.projectId === oldProjectId ? "hraness/hra-v0" : "hraness/hra",
},
schemaVersion: 2,
source: { commit: endpoint.sourceCommit },
};
return endpoint.generation === 0
? {
...shared,
publication: {
build: 15,
dmgSha256: "7ff49500de3d1fc768c17454ef7642c51f6662dfa5bf0e2ba183a85bb67fcd03",
publicationCommit: "6221f79b745f154882080936b961ff431569f33e",
releaseId: 374_980_441,
sourceCommit: "7b39c459827b2acf45aa2d911c94fdb5d4f37860",
tag: "v0.1.14",
tagObject: "37ed37afb39cacfd6a51044cf7f3c1b873571aa3",
version: endpoint.version,
},
}
: { ...shared, version: endpoint.version };
};

type MoveBehavior = "ambiguous" | "commit" | "move-and-source-alias" | "noop";

class FakeCutoverProvider implements CutoverProvider {
readonly aliasEndpoints: Record<ManagedAlias, CutoverEndpoint>;
markerBrokenForTargetAlias: ManagedAlias | undefined;
markerOverrideForTargetAlias: Readonly<{ alias: ManagedAlias; value: unknown }> | undefined;
moveBehavior: MoveBehavior = "commit";
owner: "ambiguous" | "source" | "target" = "source";
sourceAliasSetFailure: ManagedAlias | undefined;
Expand Down Expand Up @@ -185,6 +202,10 @@ class FakeCutoverProvider implements CutoverProvider {
async readMarker(aliasName: ManagedAlias): Promise<unknown> {
const endpoint = this.aliasEndpoints[aliasName];
this.operations.push(`read-marker:${aliasName}:${endpoint.deploymentId}`);
if (
this.markerOverrideForTargetAlias?.alias === aliasName
&& endpoint === this.plan.target
) return this.markerOverrideForTargetAlias.value;
if (this.markerBrokenForTargetAlias === aliasName && endpoint === this.plan.target) {
return { ...markerFor(endpoint) as object, source: { commit: "0".repeat(40) } };
}
Expand Down Expand Up @@ -272,6 +293,9 @@ describe("domain cutover runbook", () => {
expect(runbook).toContain("{id,accountId,autoAssignCustomDomains}");
expect(runbook).toContain("--prod --skip-domain");
expect(runbook).toContain("vercel curl / --deployment <deployment-id>");
expect(runbook).toContain("publication.version");
expect(runbook).toContain("top-level `version`");
expect(runbook).not.toContain("vercel curl / --deployment <deployment-id> --scope");
expect(runbook).toContain("/v4/aliases/hra-weld.vercel.app");
expect(runbook).toContain("/v4/aliases/try-hra.vercel.app");
expect(runbook).toContain("https://try-hra.vercel.app");
Expand Down Expand Up @@ -540,6 +564,50 @@ describe("domain cutover operator", () => {
);
});

test("refuses generation-zero markers with a wrong schema or top-level-only version", async () => {
for (const value of [
{ ...markerFor(oldEndpoint) as object, schemaVersion: 1 },
{
generation: 0,
product: "HRA",
repository: {
id: oldEndpoint.repositoryId,
path: "hraness/hra-v0",
},
schemaVersion: 2,
source: { commit: oldEndpoint.sourceCommit },
version: oldEndpoint.version,
},
]) {
const provider = new FakeCutoverProvider(archivePlan);
provider.markerOverrideForTargetAlias = { alias: fallbackAlias, value };

await expect(executeCutoverPlan(archivePlan, provider, {
clock: immediateClock(),
convergenceTimeoutMs: 2,
})).rejects.toMatchObject({ code: "cutover_reverted" });
expect(provider.fallbackAliasEndpoint).toBe(baselineEndpoint);
expect(provider.aliasEndpoint).toBe(baselineEndpoint);
}
});

test("refuses a generation-one marker whose version exists only under publication", async () => {
const provider = new FakeCutoverProvider(forwardPlan);
const marker = markerFor(newEndpoint) as Record<string, unknown>;
const { version, ...withoutVersion } = marker;
provider.markerOverrideForTargetAlias = {
alias: canonicalAlias,
value: { ...withoutVersion, publication: { version } },
};

await expect(executeCutoverPlan(forwardPlan, provider, {
clock: immediateClock(),
convergenceTimeoutMs: 2,
})).rejects.toMatchObject({ code: "cutover_reverted" });
expect(provider.aliasEndpoint).toBe(oldEndpoint);
expect(provider.owner).toBe("source");
});

test("restores both aliases to P if hra.sh fails after fallback Q is proven", async () => {
const provider = new FakeCutoverProvider(archivePlan);
provider.targetAliasSetFailure = canonicalAlias;
Expand Down
40 changes: 29 additions & 11 deletions scripts/domain-cutover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,29 @@ const domainsReadbackSchema = z.object({
domains: z.array(z.object({ name: z.string().min(1).max(253) })).max(1_024),
});

const markerSchema = z.object({
generation: z.union([z.literal(0), z.literal(1)]),
product: z.literal("HRA"),
repository: z.object({
id: z.number().int().positive(),
path: z.string().min(1).max(200),
}),
source: z.object({ commit: commitSchema }),
version: versionSchema,
});
const markerRepositorySchema = z.object({
id: z.number().int().positive(),
path: z.string().min(1).max(200),
}).strict();
const markerSourceSchema = z.object({ commit: commitSchema }).strict();
const markerSchema = z.discriminatedUnion("generation", [
z.object({
generation: z.literal(0),
product: z.literal("HRA"),
publication: z.object({ version: versionSchema }).passthrough(),
repository: markerRepositorySchema,
schemaVersion: z.literal(2),
source: markerSourceSchema,
}).strict(),
z.object({
generation: z.literal(1),
product: z.literal("HRA"),
repository: markerRepositorySchema,
schemaVersion: z.literal(2),
source: markerSourceSchema,
version: versionSchema,
}).strict(),
]);

export type AliasReadback = z.infer<typeof aliasReadbackSchema>;
export type DeploymentReadback = z.infer<typeof deploymentReadbackSchema>;
Expand Down Expand Up @@ -204,12 +217,17 @@ const markerMatches = (value: unknown, endpoint: CutoverEndpoint): boolean => {
if (endpoint.generation === null) return true;
const parsed = markerSchema.safeParse(value);
const expectedPath = endpoint.projectId === oldProjectId ? "hraness/hra-v0" : "hraness/hra";
const markerVersion = parsed.success
? parsed.data.generation === 0
? parsed.data.publication.version
: parsed.data.version
: null;
return parsed.success
&& parsed.data.generation === endpoint.generation
&& parsed.data.repository.id === endpoint.repositoryId
&& parsed.data.repository.path === expectedPath
&& parsed.data.source.commit === endpoint.sourceCommit
&& parsed.data.version === endpoint.version;
&& markerVersion === endpoint.version;
};

const readOwner = async (
Expand Down
Loading
Loading