Skip to content

Commit 0e29f9c

Browse files
kristof-siketclaude
andcommitted
feat(app): add --no-promote for promotionless deploys
Add a `--no-promote` flag to `prisma-cli app deploy` that builds a new deployment without flipping the live pointer. The previous deployment keeps serving live; the new one is reachable only at its own candidate URL until a later `app promote <deployment-id>` makes it live. This is the CI build-then-verify path: build the candidate, health-check its URL, then promote. Because a promotionless deploy never replaces the live deployment, it bypasses the production-confirmation gate — so `app deploy --no-promote` on the production branch works without `--prod`. Wiring: - ComputeClient.deploy already supports `skipPromote`; the provider now forwards it and maps the not-promoted return (candidate URL, live pointer stays on the previous deployment, `promoted: false`). - Controller threads `noPromote`, skips the gate when set, surfaces `promoted` + `deployment.live`, and leads nextSteps with `app promote`. - Human output renders a built-not-live headline with a promote hint; `--json` carries `promoted` and the candidate `deployment.{url,live}`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0d559ab commit 0e29f9c

10 files changed

Lines changed: 516 additions & 20 deletions

File tree

docs/product/command-spec.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,7 @@ prisma-cli app run --build-type bun --entry server.ts --port 3000
10121012
prisma-cli app run api
10131013
```
10141014

1015-
## `prisma-cli app deploy [app] --project <id-or-name> --create-project <name> --app <name> --branch <name> --framework <nextjs|nuxt|astro|hono|nestjs|tanstack-start|custom|bun> --entry <path> --http-port <port> --region <region> --env <name=value|file> --db --no-db --prod`
1015+
## `prisma-cli app deploy [app] --project <id-or-name> --create-project <name> --app <name> --branch <name> --framework <nextjs|nuxt|astro|hono|nestjs|tanstack-start|custom|bun> --entry <path> --http-port <port> --region <region> --env <name=value|file> --db --no-db --prod --no-promote`
10161016

10171017
Purpose:
10181018

@@ -1053,7 +1053,7 @@ same reasons they are excluded today.
10531053
- the `[app]` argument selects an `apps` target by key:
10541054
- without an `[app]` argument, a command run from inside a target's `root` selects that target, so `cd apps/api && prisma-cli app deploy` deploys `api`; the deepest matching root wins and an ambiguous tie selects nothing
10551055
- with multiple `apps` entries, no `[app]` argument, and no target inferred from the invocation directory, deploy deploys every target sequentially in declaration order — the config declares the system, and a bare `prisma-cli app deploy` ships it
1056-
- deploying all targets rejects per-app inputs (`--app`, `--framework`, `--entry`, `--http-port`, `--region`, `--env`, `PRISMA_APP_ID`) with a usage error; project- and branch-level flags (`--project`, `--create-project`, `--branch`, `--db`, `--no-db`, `--prod`, `--yes`) apply to the whole run, with `--create-project` creating and binding the Project once before the first target
1056+
- deploying all targets rejects per-app inputs (`--app`, `--framework`, `--entry`, `--http-port`, `--region`, `--env`, `PRISMA_APP_ID`) with a usage error; project- and branch-level flags (`--project`, `--create-project`, `--branch`, `--db`, `--no-db`, `--prod`, `--no-promote`, `--yes`) apply to the whole run, with `--create-project` creating and binding the Project once before the first target
10571057
- a deploy-all run stops at the first failure and reports the targets already live; `--json` output aggregates one full deploy result per target
10581058
- `app build` and `app run` still require a target in multi-app configs and fail with `COMPUTE_CONFIG_TARGET_REQUIRED` (a dev server cannot run N apps at once; build keeps the same shape)
10591059
- an `[app]` argument that matches no target fails with `COMPUTE_CONFIG_TARGET_UNKNOWN`
@@ -1120,6 +1120,8 @@ Behavior:
11201120
- resolves or creates app context inside the resolved branch from `--app`, `PRISMA_APP_ID`, `package.json#name`, or current directory name
11211121
- auto-promotes the first production deploy for an App without `--prod`
11221122
- requires `--prod` for subsequent deploys to a production Branch; `--yes` only skips the confirmation prompt when `--prod` is also present
1123+
- supports `--no-promote` to build a new deployment without promoting it to live: the previous deployment keeps serving, and the new deployment is reachable only at its own candidate URL until a later `app promote <deployment-id>` makes it live
1124+
- `--no-promote` never replaces the live deployment, so it bypasses the production gate: `app deploy --no-promote` on a production Branch builds a candidate without `--prod` and without confirmation. It is the CI build-then-verify path — build the candidate, health-check its URL, then `app promote`
11231125
- does not prompt when there is no real choice; zero matching apps creates the inferred app
11241126
- writes `.prisma/local.json` after Project binding succeeds and before build/deploy starts, so retries after a failed deploy do not repeat setup
11251127
- before asking `Customize build settings? (y/N)`, previews the detected framework and runtime so the user can see the defaults they are accepting or changing
@@ -1135,6 +1137,7 @@ Behavior:
11351137
- after setup, deploy prints `Deploying to <Project> / <Branch> / <App>`; later deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1`
11361138
- deploy progress uses short stage copy (`Building locally...`, `Built <size>`, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...`
11371139
- success human output prints `Live in <duration>`, the URL on its own line, and `Logs prisma-cli app logs`
1140+
- with `--no-promote`, success human output instead prints `Built <deployment-id> in <duration> (not promoted)`, the candidate URL on its own line, a note that the live deployment is unchanged, and a `Promote prisma-cli app promote <deployment-id>` next step
11381141
- accepts repeated `--env NAME=VALUE` flags and dotenv file paths such as `--env .env`
11391142
- supports `--db` to create a new empty Prisma Postgres database and write `DATABASE_URL` and `DIRECT_URL` through the existing `project env` storage; the CLI never runs schema or migration commands — applying the schema stays with the user's own tooling
11401143
- `--db` is the opinionated single-database path: it creates **one** branch database and exposes `DATABASE_URL`/`DIRECT_URL` as branch-scoped env vars, so every app on the branch shares it. In a multi-app deploy-all run the database is created once (on the first target) and reused by the rest; the run never creates a database per app. Per-app or per-service database topologies are explicit-configuration territory — set each app's database env vars yourself rather than relying on `--db` to infer app-to-database ownership
@@ -1176,6 +1179,7 @@ prisma-cli app deploy --no-db
11761179
prisma-cli app deploy --framework nextjs --http-port 3000
11771180
prisma-cli app deploy --branch feat-login --framework hono --http-port 3000
11781181
prisma-cli app deploy --prod --yes
1182+
prisma-cli app deploy --no-promote
11791183
prisma-cli app deploy --framework bun --entry src/server.ts --http-port 3000
11801184
prisma-cli app deploy --entry src/server.ts --http-port 3000
11811185
prisma-cli app deploy web

packages/cli/src/commands/app/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,13 @@ function createDeployCommand(runtime: CliRuntime): Command {
247247
),
248248
)
249249
.addOption(new Option("--no-db", "Skip database setup"))
250-
.addOption(new Option("--prod", "Confirm intent to deploy to production"));
250+
.addOption(new Option("--prod", "Confirm intent to deploy to production"))
251+
.addOption(
252+
new Option(
253+
"--no-promote",
254+
"Build the new deployment without promoting it to live (promote later with app promote <id>)",
255+
),
256+
);
251257
addGlobalFlags(command);
252258

253259
command.action(async (configTarget: string | undefined, options) => {
@@ -262,6 +268,7 @@ function createDeployCommand(runtime: CliRuntime): Command {
262268
const createProjectName = (options as { createProject?: string })
263269
.createProject;
264270
const prod = (options as { prod?: boolean }).prod;
271+
const noPromote = (options as { promote?: boolean }).promote === false;
265272
const db = (options as { db?: boolean }).db;
266273
const hasDbConflict =
267274
hasFlag(runtime.argv, "--db") && hasFlag(runtime.argv, "--no-db");
@@ -291,6 +298,7 @@ function createDeployCommand(runtime: CliRuntime): Command {
291298
region,
292299
envAssignments,
293300
prod: prod === true,
301+
noPromote,
294302
db,
295303
configTarget,
296304
});

packages/cli/src/controllers/app.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ interface AppDeployOptions {
387387
region?: string;
388388
envAssignments?: string[];
389389
prod?: boolean;
390+
noPromote?: boolean;
390391
db?: boolean;
391392
configTarget?: string;
392393
}
@@ -739,16 +740,18 @@ async function runSingleAppDeploy(
739740
framework = customized.framework;
740741
runtime = customized.runtime;
741742

742-
const productionDeployGate = await enforceProductionDeployGate(
743-
context,
744-
provider,
745-
{
746-
appId: selectedApp.appId,
747-
appName: selectedApp.displayName,
748-
branchKind: target.branch.kind,
749-
prod: options?.prod === true,
750-
},
751-
);
743+
const noPromote = options?.noPromote === true;
744+
// A promotionless deploy never replaces the live deployment, so the
745+
// production-confirmation gate does not apply: --no-promote on the production
746+
// branch builds a candidate without --prod.
747+
const productionDeployGate = noPromote
748+
? { firstProductionDeploy: false }
749+
: await enforceProductionDeployGate(context, provider, {
750+
appId: selectedApp.appId,
751+
appName: selectedApp.displayName,
752+
branchKind: target.branch.kind,
753+
prod: options?.prod === true,
754+
});
752755

753756
// Customization can switch from a Bun-compatible framework to one that
754757
// derives its entrypoint from build output, so validate --entry again after it.
@@ -801,6 +804,7 @@ async function runSingleAppDeploy(
801804
buildSettings: buildSettingsResolution.settings,
802805
portMapping,
803806
envVars,
807+
skipPromote: noPromote,
804808
interaction: undefined,
805809
signal: context.runtime.signal,
806810
progress: createDeployProgress(
@@ -838,6 +842,7 @@ async function runSingleAppDeploy(
838842
name: deployResult.app.name,
839843
},
840844
deployment: deployResult.deployment,
845+
promoted: deployResult.promoted,
841846
deploySettings: {
842847
config: {
843848
path: buildSettingsResolution.relativeConfigPath,
@@ -871,10 +876,15 @@ async function runSingleAppDeploy(
871876
...legacyWarnings,
872877
...branchDatabaseSetup.warnings,
873878
],
874-
nextSteps: [
875-
"prisma-cli app list-deploys",
876-
`prisma-cli app show-deploy ${deployResult.deployment.id}`,
877-
],
879+
nextSteps: deployResult.promoted
880+
? [
881+
"prisma-cli app list-deploys",
882+
`prisma-cli app show-deploy ${deployResult.deployment.id}`,
883+
]
884+
: [
885+
`prisma-cli app promote ${deployResult.deployment.id}`,
886+
`prisma-cli app show-deploy ${deployResult.deployment.id}`,
887+
],
878888
};
879889
}
880890

packages/cli/src/lib/app/app-provider.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ export interface DeployRecord {
6565
id: string;
6666
status: string;
6767
url: string | null;
68+
live: boolean;
6869
};
70+
promoted: boolean;
6971
}
7072

7173
export interface EnvRecord {
@@ -229,6 +231,7 @@ export interface AppProvider {
229231
buildSettings?: AppBuildSettings;
230232
portMapping?: PortMapping;
231233
envVars?: Record<string, string>;
234+
skipPromote?: boolean;
232235
interaction?: unknown;
233236
signal?: AbortSignal;
234237
progress?: unknown;
@@ -511,6 +514,7 @@ export function createAppProvider(
511514
region: resolvedApp.region,
512515
portMapping: options.portMapping,
513516
envVars: options.envVars,
517+
skipPromote: options.skipPromote,
514518
timeoutSeconds: 120,
515519
pollIntervalMs: 2000,
516520
interaction: options.interaction as never,
@@ -524,13 +528,18 @@ export function createAppProvider(
524528

525529
const deployed = deployResult.value;
526530

531+
// On a promotionless deploy the SDK leaves appEndpointDomain null and the
532+
// previous deployment serving live, so the live pointer stays on the old
533+
// deployment and both URL expressions resolve to the candidate endpoint.
527534
return {
528535
projectId: deployed.projectId,
529536
app: {
530537
id: deployed.appId,
531538
name: deployed.appName,
532539
region: deployed.region ?? null,
533-
liveDeploymentId: deployed.deploymentId,
540+
liveDeploymentId: deployed.promoted
541+
? deployed.deploymentId
542+
: deployed.previousDeploymentId,
534543
liveUrl: toAbsoluteUrl(deployed.appEndpointDomain ?? null),
535544
},
536545
deployment: {
@@ -541,7 +550,9 @@ export function createAppProvider(
541550
deployed.deploymentEndpointDomain ??
542551
null,
543552
),
553+
live: deployed.promoted,
544554
},
555+
promoted: deployed.promoted,
545556
};
546557
},
547558

packages/cli/src/presenters/app.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,33 @@ export function renderAppDeploy(
6868
const logsCommand = options?.logsTarget
6969
? `prisma-cli app logs ${options.logsTarget}`
7070
: "prisma-cli app logs";
71+
const headline = result.promoted
72+
? [
73+
`Live in ${formatDuration(result.durationMs)}`,
74+
...(result.deployment.url
75+
? [context.ui.link(result.deployment.url)]
76+
: []),
77+
]
78+
: [
79+
`Built ${result.deployment.id} in ${formatDuration(result.durationMs)} (not promoted)`,
80+
...(result.deployment.url
81+
? [context.ui.link(result.deployment.url)]
82+
: []),
83+
context.ui.dim("The live deployment is unchanged."),
84+
];
7185
const lines = [
72-
`Live in ${formatDuration(result.durationMs)}`,
73-
...(result.deployment.url ? [context.ui.link(result.deployment.url)] : []),
86+
...headline,
7487
...renderBranchDatabaseDeploySummary(context, result),
7588
"",
7689
...renderDeployOutputRows(context.ui, [
90+
...(result.promoted
91+
? []
92+
: [
93+
{
94+
label: "Promote",
95+
value: `prisma-cli app promote ${result.deployment.id}`,
96+
},
97+
]),
7798
{ label: "Logs", value: logsCommand },
7899
]),
79100
...renderDeployResolvedContextBlock(context, result),

packages/cli/src/types/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ export interface AppDeployResult {
7575
id: string;
7676
status: string;
7777
url: string | null;
78+
live: boolean;
7879
};
80+
/** Whether the new deployment was promoted to live. False for --no-promote. */
81+
promoted: boolean;
7982
deploySettings: AppDeploySettings;
8083
durationMs: number;
8184
localPin?: {

0 commit comments

Comments
 (0)