Skip to content

Commit 34b4312

Browse files
fix(app): resolve the custom-domain production branch by role, not name
Custom-domain commands required a branch literally named `production`: `toBranchKind` only treats `production`/`main` as production, and the flow used the literal name "production". So a project whose production branch is named e.g. `master` could never attach a custom domain — `domain add` looked for a "production" branch, found none, and failed with "no app on the production branch". Resolve the production branch from the project's branches by role (the durable default branch) and use its real name. The production-only gate now checks the resolved branch's role instead of guessing from the name. - read-branch.ts: extract listProjectBranches; add resolveProductionBranch (match an explicit --branch by name, else the production-role / default branch). - resolveProjectContext: a `productionBranch` mode resolves by role. - resolveAppDomainTarget: drop the early name-based gate; gate on the resolved branch's role after resolution. Tests: the branch mock now marks the default branch as production role; new coverage for a `master`-named production branch and for rejecting a real preview branch by its role.
1 parent 577152d commit 34b4312

4 files changed

Lines changed: 224 additions & 40 deletions

File tree

packages/cli/src/controllers/app.ts

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ import {
8686
runLocalApp,
8787
} from "../lib/app/local-dev";
8888
import { enforceProductionDeployGate } from "../lib/app/production-deploy-gate";
89-
import { resolveReadBranch } from "../lib/app/read-branch";
89+
import {
90+
resolveProductionBranch,
91+
resolveReadBranch,
92+
} from "../lib/app/read-branch";
9093
import { readAuthState } from "../lib/auth/auth-ops";
9194
import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client";
9295
import { requireComputeAuth } from "../lib/auth/guard";
@@ -2050,17 +2053,6 @@ async function resolveAppDomainTarget(
20502053
commandName.replace(/^app /, ""),
20512054
);
20522055
const branch = resolveDomainBranch(options?.branchName);
2053-
if (toBranchKind(branch.name) !== "production") {
2054-
throw new CliError({
2055-
code: "BRANCH_NOT_DEPLOYABLE",
2056-
domain: "branch",
2057-
summary: "Custom domains require the production branch",
2058-
why: `Custom domains on preview branch "${branch.name}" are not supported in Public Beta.`,
2059-
fix: "Use --branch production, or attach the domain after promoting/deploying to the production branch.",
2060-
exitCode: 2,
2061-
nextSteps: ["prisma-cli app domain add <hostname> --branch production"],
2062-
});
2063-
}
20642056

20652057
const envProjectId = readDeployEnvOverride(
20662058
context,
@@ -2071,10 +2063,27 @@ async function resolveAppDomainTarget(
20712063
const { provider, target, projectId } =
20722064
await requireProviderAndProjectContext(context, options?.projectRef, {
20732065
branch,
2066+
// Domains attach to the production branch; resolve it by role (not by the
2067+
// literal name "production") so a project whose production branch is
2068+
// named e.g. `master` still works.
2069+
productionBranch: true,
20742070
commandName,
20752071
envProjectId,
20762072
projectDir: compute.projectDir,
20772073
});
2074+
2075+
// Gate on the resolved branch's role, not a guess from its name.
2076+
if (target.branch.kind !== "production") {
2077+
throw new CliError({
2078+
code: "BRANCH_NOT_DEPLOYABLE",
2079+
domain: "branch",
2080+
summary: "Custom domains require the production branch",
2081+
why: `Custom domains on preview branch "${target.branch.name}" are not supported in Public Beta.`,
2082+
fix: "Use --branch <production-branch>, or attach the domain after promoting/deploying to the production branch.",
2083+
exitCode: 2,
2084+
nextSteps: ["prisma-cli app domain add <hostname>"],
2085+
});
2086+
}
20782087
const apps = await listApps(context, provider, projectId, target.branch.name);
20792088
const selectedApp = await resolveDomainAppSelection(
20802089
context,
@@ -2111,7 +2120,9 @@ function resolveDomainBranch(
21112120
): ResolvedDeployBranch {
21122121
return {
21132122
name: explicitBranchName?.trim() || "production",
2114-
annotation: explicitBranchName ? "set by --branch" : "production default",
2123+
annotation: explicitBranchName
2124+
? BRANCH_FLAG_ANNOTATION
2125+
: "production default",
21152126
};
21162127
}
21172128

@@ -3188,6 +3199,7 @@ async function requireProviderAndProjectContext(
31883199
explicitProject: string | undefined,
31893200
options?: {
31903201
branch?: ResolvedDeployBranch;
3202+
productionBranch?: boolean;
31913203
commandName?: string;
31923204
envProjectId?: string;
31933205
projectDir?: string;
@@ -3252,6 +3264,7 @@ async function resolveProjectContext(
32523264
explicitProject: string | undefined,
32533265
options?: {
32543266
branch?: ResolvedDeployBranch;
3267+
productionBranch?: boolean;
32553268
commandName?: string;
32563269
envProjectId?: string;
32573270
projectDir?: string;
@@ -3283,6 +3296,28 @@ async function resolveProjectContext(
32833296
const requested =
32843297
options?.branch ?? (await resolveDeployBranch(context, undefined));
32853298

3299+
const fallback = {
3300+
id: null,
3301+
name: requested.name,
3302+
kind: toBranchKind(requested.name),
3303+
};
3304+
3305+
if (options?.productionBranch) {
3306+
// Production-only commands (custom domains) target the production branch.
3307+
// Resolve it by role so a project whose production branch is named e.g.
3308+
// `master` still resolves, instead of guessing from the literal name. An
3309+
// explicit --branch is matched by name so the caller can validate its role.
3310+
const production = await resolveProductionBranch(client, {
3311+
projectId: resolved.project.id,
3312+
branchName:
3313+
options.branch?.annotation === BRANCH_FLAG_ANNOTATION
3314+
? requested.name
3315+
: undefined,
3316+
signal: context.runtime.signal,
3317+
});
3318+
return { ...resolved, branch: production ?? fallback };
3319+
}
3320+
32863321
// An explicit --branch is honored as-is. An inferred branch (active Git
32873322
// branch or the default) is resolved against the project's branches and
32883323
// falls back to the default branch so a git-push app on a non-`main`
@@ -3295,14 +3330,7 @@ async function resolveProjectContext(
32953330
signal: context.runtime.signal,
32963331
});
32973332

3298-
return {
3299-
...resolved,
3300-
branch: remoteBranch ?? {
3301-
id: null,
3302-
name: requested.name,
3303-
kind: toBranchKind(requested.name),
3304-
},
3305-
};
3333+
return { ...resolved, branch: remoteBranch ?? fallback };
33063334
}
33073335

33083336
async function resolveDeployProjectContext(
@@ -3632,6 +3660,9 @@ function assertExclusiveDeployProjectInputs(options: {
36323660
);
36333661
}
36343662

3663+
/** Branch annotation marking a branch the user named via `--branch`. */
3664+
const BRANCH_FLAG_ANNOTATION = "set by --branch";
3665+
36353666
interface ResolvedDeployBranch {
36363667
name: string;
36373668
annotation: string;
@@ -3644,7 +3675,7 @@ async function resolveDeployBranch(
36443675
if (explicitBranchName) {
36453676
return {
36463677
name: explicitBranchName,
3647-
annotation: "set by --branch",
3678+
annotation: BRANCH_FLAG_ANNOTATION,
36483679
};
36493680
}
36503681

packages/cli/src/lib/app/read-branch.ts

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@ export interface ReadBranch {
88
kind: BranchKind;
99
}
1010

11+
interface ProjectBranch extends ReadBranch {
12+
isDefault: boolean;
13+
}
14+
1115
/**
12-
* Resolves the branch an app management command should read from, without ever
13-
* creating one. Returns the branch whose `gitName` matches `branchName`, else
14-
* the project's default branch, else null when the project has no branches.
16+
* Lists every branch in a project, following pagination. Management commands
17+
* read branch context against this list so they never create a branch.
1518
*/
16-
export async function resolveReadBranch(
19+
export async function listProjectBranches(
1720
client: ManagementApiClient,
18-
options: { projectId: string; branchName: string; signal?: AbortSignal },
19-
): Promise<ReadBranch | null> {
21+
options: { projectId: string; signal?: AbortSignal },
22+
): Promise<ProjectBranch[]> {
2023
const branches: Array<{
2124
id: string;
2225
gitName: string;
@@ -42,11 +45,62 @@ export async function resolveReadBranch(
4245
: undefined;
4346
} while (cursor);
4447

48+
return branches.map((branch) => ({
49+
id: branch.id,
50+
name: branch.gitName,
51+
kind: branch.role,
52+
isDefault: branch.isDefault,
53+
}));
54+
}
55+
56+
/**
57+
* Resolves the branch an app management command should read from, without ever
58+
* creating one. Returns the branch whose name matches `branchName`, else the
59+
* project's default branch, else null when the project has no branches.
60+
*/
61+
export async function resolveReadBranch(
62+
client: ManagementApiClient,
63+
options: { projectId: string; branchName: string; signal?: AbortSignal },
64+
): Promise<ReadBranch | null> {
65+
const branches = await listProjectBranches(client, {
66+
projectId: options.projectId,
67+
signal: options.signal,
68+
});
69+
const chosen =
70+
branches.find((branch) => branch.name === options.branchName) ??
71+
branches.find((branch) => branch.isDefault) ??
72+
null;
73+
return chosen
74+
? { id: chosen.id, name: chosen.name, kind: chosen.kind }
75+
: null;
76+
}
77+
78+
/**
79+
* Resolves a project's production branch by role, not by name. The production
80+
* branch is the durable default branch; resolving it from the API keeps
81+
* production-only commands (custom domains) working when that branch is named
82+
* something other than `production` or `main` (for example `master`).
83+
*
84+
* When `branchName` is given (an explicit `--branch`), that branch is matched
85+
* first so the caller can validate its role; otherwise the production-role
86+
* branch (falling back to the default branch) is returned.
87+
*/
88+
export async function resolveProductionBranch(
89+
client: ManagementApiClient,
90+
options: { projectId: string; branchName?: string; signal?: AbortSignal },
91+
): Promise<ReadBranch | null> {
92+
const branches = await listProjectBranches(client, {
93+
projectId: options.projectId,
94+
signal: options.signal,
95+
});
4596
const chosen =
46-
branches.find((branch) => branch.gitName === options.branchName) ??
97+
(options.branchName
98+
? branches.find((branch) => branch.name === options.branchName)
99+
: undefined) ??
100+
branches.find((branch) => branch.kind === "production") ??
47101
branches.find((branch) => branch.isDefault) ??
48102
null;
49103
return chosen
50-
? { id: chosen.id, name: chosen.gitName, kind: chosen.role }
104+
? { id: chosen.id, name: chosen.name, kind: chosen.kind }
51105
: null;
52106
}

packages/cli/tests/app-controller.test.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ function expectedAppVerboseContext() {
7373
branch: {
7474
id: "branch_main",
7575
name: "main",
76-
kind: "preview",
76+
kind: "production",
7777
},
7878
resolution: {
7979
projectSource: "local-pin",
@@ -791,7 +791,7 @@ describe("app controller", () => {
791791
name: "Acme Dashboard",
792792
},
793793
branch: {
794-
name: "production",
794+
name: "main",
795795
kind: "production",
796796
},
797797
app: {
@@ -1336,6 +1336,15 @@ describe("app controller", () => {
13361336
});
13371337

13381338
it("domain add rejects preview branches", async () => {
1339+
const requireComputeAuth = vi.fn().mockResolvedValue(
1340+
createProjectClient("proj_123", {
1341+
extraBranches: [{ name: "feat/login", role: "preview" }],
1342+
}),
1343+
);
1344+
vi.doMock("../src/lib/auth/guard", () => ({
1345+
requireComputeAuth,
1346+
}));
1347+
13391348
const { createTempCwd, createTestCommandContext } = await import(
13401349
"./helpers"
13411350
);
@@ -1351,6 +1360,8 @@ describe("app controller", () => {
13511360
},
13521361
});
13531362

1363+
// The project's production branch is `main`; `feat/login` is a real preview
1364+
// branch, so resolving it by role rejects the domain on its actual role.
13541365
await expect(
13551366
runAppDomainAdd(context, "shop.acme.com", {
13561367
projectRef: "proj_123",
@@ -1364,6 +1375,76 @@ describe("app controller", () => {
13641375
});
13651376
});
13661377

1378+
it("resolves the production branch by role when it is named master", async () => {
1379+
const requireComputeAuth = vi
1380+
.fn()
1381+
.mockResolvedValue(
1382+
createProjectClient("proj_123", { defaultBranchName: "master" }),
1383+
);
1384+
const activeDomain = createDomain({ status: "active" });
1385+
const listApps = vi.fn().mockResolvedValue([
1386+
{
1387+
id: "app_1",
1388+
name: "shop",
1389+
region: "eu-central-1",
1390+
liveDeploymentId: "dep_live",
1391+
liveUrl: "https://shop.prisma.app",
1392+
},
1393+
]);
1394+
const addDomain = vi
1395+
.fn()
1396+
.mockResolvedValue({ domain: activeDomain, existing: true });
1397+
1398+
vi.doMock("../src/lib/auth/guard", () => ({
1399+
requireComputeAuth,
1400+
}));
1401+
vi.doMock("../src/lib/app/app-provider", async (importOriginal) => {
1402+
const actual =
1403+
await importOriginal<typeof import("../src/lib/app/app-provider")>();
1404+
return {
1405+
...actual,
1406+
createAppProvider: vi.fn(() =>
1407+
withBranchDatabaseProviderDefaults({
1408+
resolveBranch: createResolveBranch(),
1409+
listApps,
1410+
listDomains: vi.fn(),
1411+
addDomain,
1412+
retryDomain: vi.fn(),
1413+
}),
1414+
),
1415+
};
1416+
});
1417+
1418+
const { createTempCwd, createTestCommandContext } = await import(
1419+
"./helpers"
1420+
);
1421+
const { runAppDomainAdd } = await import("../src/controllers/app");
1422+
const cwd = await createTempCwd();
1423+
const stateDir = path.join(cwd, ".state");
1424+
const { context } = await createTestCommandContext({
1425+
cwd,
1426+
stateDir,
1427+
env: {
1428+
...process.env,
1429+
PRISMA_CLI_MOCK_FIXTURE_PATH: undefined,
1430+
},
1431+
});
1432+
1433+
// No --branch: the production branch is resolved by role, so the domain is
1434+
// added on `master` instead of failing because no branch is named
1435+
// "production".
1436+
const result = await runAppDomainAdd(context, "shop.acme.com", {
1437+
projectRef: "proj_123",
1438+
appName: "shop",
1439+
});
1440+
1441+
expect(addDomain).toHaveBeenCalled();
1442+
expect(result.result).toMatchObject({
1443+
branch: { name: "master", kind: "production" },
1444+
domain: { hostname: "shop.acme.com" },
1445+
});
1446+
});
1447+
13671448
it("domain retry maps API 409 to DOMAIN_RETRY_NOT_ELIGIBLE", async () => {
13681449
const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient());
13691450
const listApps = vi.fn().mockResolvedValue([

0 commit comments

Comments
 (0)