Skip to content

Commit dd385aa

Browse files
committed
feat(deploy): add --dry-run flag for instant deployability feedback
`sanity deploy --dry-run` answers "can this deploy" without deploying — a fast feedback loop for agents and CI. It reuses the plan phase of the deploy sequence read-only: target resolution becomes a verdict instead of a prompt or creation, the schema worker validates and extracts without uploading, and the output directory is listed instead of packed. Failures aggregate so one run reports every problem, never prompts, and exits 2 when not deployable. `--json` emits the {deployable, checks, target, files} report via oclif's enableJsonFlag; the exit code is set without throwing so the full report still reaches stdout on failure. The aggregating checks adapter is the only new seam implementation — real deploys keep failing fast through the same steps.
1 parent 074e2b7 commit dd385aa

11 files changed

Lines changed: 940 additions & 127 deletions

File tree

.changeset/pr-1255.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sanity/cli': minor
3+
---
4+
5+
feat(deploy): add --dry-run flag for instant deployability feedback

packages/@sanity/cli/src/actions/deploy/deployApp.ts

Lines changed: 122 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -23,75 +23,115 @@ import {createUserApplicationForApp} from './createUserApplicationForApp.js'
2323
import {
2424
checkAutoUpdates,
2525
checkBuild,
26+
checkOutputDir,
2627
checkPackageVersion,
28+
createAggregatingChecks,
2729
createFailFastChecks,
2830
type DeployChecks,
2931
verifyOutputDir,
3032
} from './deployChecks.js'
3133
import {deployDebug} from './deployDebug.js'
34+
import {checkAppTarget, type DeployTarget} from './deployTargetChecks.js'
35+
import {type DryRunReport, isDeployable} from './dryRunReport.js'
3236
import {findUserApplicationForApp} from './findUserApplicationForApp.js'
33-
import {type DeployAppOptions} from './types.js'
37+
import {type DeployFileSummary} from './listDeploymentFiles.js'
38+
import {type DeployAppOptions, type DeployFlags} from './types.js'
3439

3540
type Workbench = ReturnType<typeof getWorkbench>
3641

3742
/**
38-
* Builds and deploys a Sanity application: a plan phase validates and produces
39-
* the deployable artifacts, then an execute phase ships them.
43+
* Builds and deploys a Sanity application. A plan phase validates and produces
44+
* the deployable artifacts; with `--dry-run` it runs read-only and returns a
45+
* report instead of shipping.
4046
*
4147
* @internal
4248
*/
43-
export async function deployApp(options: DeployAppOptions): Promise<void> {
44-
const {cliConfig, output} = options
49+
export async function deployApp(options: DeployAppOptions): Promise<DryRunReport | undefined> {
50+
const {cliConfig, flags, output} = options
51+
const workbench = getWorkbench(cliConfig)
52+
53+
if (flags['dry-run']) {
54+
const checks = createAggregatingChecks()
55+
if (workbench) {
56+
try {
57+
workbench.assertDeployable()
58+
} catch (err) {
59+
checks.add({message: getErrorMessage(err), name: 'app-deployable', status: 'fail'})
60+
}
61+
}
62+
const {files, target} = await planAppDeployment(options, checks, workbench, true)
63+
return {
64+
checks: checks.all(),
65+
deployable: isDeployable(checks.all()),
66+
dryRun: true,
67+
files,
68+
target,
69+
}
70+
}
4571

4672
// A federated app with no entry, view or service would ship a remote with
4773
// nothing to load — fail before any prompts or API calls.
48-
const workbench = getWorkbench(cliConfig)
4974
if (workbench) {
5075
try {
5176
workbench.assertDeployable()
5277
} catch (err) {
5378
output.error(getErrorMessage(err), {exit: exitCodes.USAGE_ERROR})
54-
return
79+
return undefined
5580
}
5681
}
5782

5883
try {
5984
const checks = createFailFastChecks(output)
60-
const plan = await planAppDeployment(options, checks, workbench)
61-
if (plan) await executeAppDeployment(plan, options, workbench)
85+
const {application, isAutoUpdating, manifest, version} = await planAppDeployment(
86+
options,
87+
checks,
88+
workbench,
89+
false,
90+
)
91+
if (application && version) {
92+
await executeAppDeployment(
93+
{application, isAutoUpdating, manifest, version},
94+
options,
95+
workbench,
96+
)
97+
}
6298
} catch (error) {
6399
// Don't throw a generic error when the user cancels a prompt
64100
if (error.name === 'ExitPromptError') {
65101
output.error('Deployment cancelled by user', {exit: 1})
66-
return
102+
return undefined
67103
}
68104
if (error instanceof CLIError) {
69105
const {message, ...errorOptions} = error
70106
output.error(message, {...errorOptions, exit: 1})
71-
return
107+
return undefined
72108
}
73109
deployDebug('Error deploying application', error)
74110
output.error(`Error deploying application: ${error}`, {exit: 1})
75111
}
112+
return undefined
76113
}
77114

78-
interface AppDeploymentPlan {
79-
application: UserApplication
115+
interface AppPlanResult {
116+
application: UserApplication | null
117+
files: DeployFileSummary | null
80118
isAutoUpdating: boolean
81119
manifest: CoreAppManifest | undefined
82-
version: string
120+
target: DeployTarget | null
121+
version: string | null
83122
}
84123

85124
/**
86-
* Validates the app deploy and produces everything a deploy needs: the target
87-
* application (title synced from the manifest) and the resolved version. Steps
88-
* report through `checks`; a real deploy fails fast.
125+
* The app deploy sequence, shared by real deploys and dry runs. Validations
126+
* report through `checks` (fail fast for real deploys, aggregated for dry runs);
127+
* side-effecting steps branch on `dryRun`.
89128
*/
90129
async function planAppDeployment(
91130
options: DeployAppOptions,
92131
checks: DeployChecks,
93132
workbench: Workbench,
94-
): Promise<AppDeploymentPlan | null> {
133+
dryRun: boolean,
134+
): Promise<AppPlanResult> {
95135
const {cliConfig, flags, output, projectRoot, sourceDir} = options
96136
const workDir = projectRoot.directory
97137
const organizationId = cliConfig.app?.organizationId
@@ -110,19 +150,27 @@ async function planAppDeployment(
110150
: {message: NO_ORGANIZATION_ID, name: 'organization-id', status: 'fail'},
111151
)
112152

153+
let application: UserApplication | null = null
154+
let target: DeployTarget | null = null
113155
if (flags.external) {
114156
checks.add({message: EXTERNAL_APP_NOT_SUPPORTED, name: 'target', status: 'fail'})
157+
} else if (dryRun) {
158+
;({existingApp: application, target} = await checkAppTarget(checks, {
159+
appId: getAppId(cliConfig),
160+
organizationId,
161+
}))
162+
} else {
163+
application = await resolveAppApplication(options)
115164
}
116165

117-
let application = await resolveAppApplication(options)
118-
119166
await checkBuild(checks, {
120167
build: () =>
121168
buildApp({
122169
autoUpdatesEnabled: isAutoUpdating,
123170
calledFromDeploy: true,
124171
cliConfig,
125-
flags,
172+
// Dry runs never prompt
173+
flags: dryRun ? ({...flags, yes: true} as DeployFlags) : flags,
126174
outDir: sourceDir,
127175
output,
128176
workDir,
@@ -133,17 +181,58 @@ async function planAppDeployment(
133181
successMessage: 'App built',
134182
})
135183

136-
await verifyOutputDir({output, sourceDir, workbench})
184+
let files: DeployFileSummary | null = null
185+
if (dryRun) {
186+
files = await checkOutputDir(checks, {sourceDir, workbench})
187+
} else {
188+
await verifyOutputDir({output, sourceDir, workbench})
189+
}
190+
191+
let manifest: CoreAppManifest | undefined
192+
let manifestFailed = false
193+
try {
194+
manifest = await extractCoreAppManifest({workDir})
195+
} catch (err) {
196+
deployDebug('Error extracting app manifest', err)
197+
// Manifests aren't strictly essential, so a real deploy warns and continues
198+
checks.add({
199+
message: `Error extracting app manifest: ${getErrorMessage(err)}`,
200+
name: 'app-manifest',
201+
status: 'warn',
202+
})
203+
manifestFailed = true
204+
}
205+
206+
const titleUpdate = application ? resolveTitleUpdate(manifest, application) : null
137207

138-
const manifest = await extractAppManifest(checks, workDir)
139-
application = await syncAppTitle({application, manifest, output})
208+
if (dryRun) {
209+
if (!manifestFailed) {
210+
checks.add({
211+
message: titleUpdate
212+
? titleUpdate.from
213+
? `Would update application title from "${titleUpdate.from}" to "${titleUpdate.to}"`
214+
: `Would set application title to "${titleUpdate.to}"`
215+
: manifest
216+
? 'App manifest extracted'
217+
: 'No app manifest (no icon or title in app configuration)',
218+
name: 'app-manifest',
219+
status: 'pass',
220+
})
221+
}
222+
} else if (application && titleUpdate) {
223+
application = await syncAppTitle(application, titleUpdate, output)
224+
}
140225

141-
if (!application || !version) return null
142-
return {application, isAutoUpdating, manifest, version}
226+
return {application, files, isAutoUpdating, manifest, target, version}
143227
}
144228

145229
async function executeAppDeployment(
146-
plan: AppDeploymentPlan,
230+
plan: {
231+
application: UserApplication
232+
isAutoUpdating: boolean
233+
manifest: CoreAppManifest | undefined
234+
version: string
235+
},
147236
options: DeployAppOptions,
148237
workbench: Workbench,
149238
): Promise<void> {
@@ -229,38 +318,12 @@ async function resolveAppApplication(options: DeployAppOptions): Promise<UserApp
229318
return application
230319
}
231320

232-
/** Extracts the app manifest; a failure warns and continues (it isn't essential). */
233-
async function extractAppManifest(
234-
checks: DeployChecks,
235-
workDir: string,
236-
): Promise<CoreAppManifest | undefined> {
237-
try {
238-
return await extractCoreAppManifest({workDir})
239-
} catch (err) {
240-
deployDebug('Error extracting app manifest', err)
241-
checks.add({
242-
message: `Error extracting app manifest: ${getErrorMessage(err)}`,
243-
name: 'app-manifest',
244-
status: 'warn',
245-
})
246-
return undefined
247-
}
248-
}
249-
250-
/** Syncs the application title from the manifest when it has changed. */
251-
async function syncAppTitle({
252-
application,
253-
manifest,
254-
output,
255-
}: {
256-
application: UserApplication | null
257-
manifest: CoreAppManifest | undefined
258-
output: DeployAppOptions['output']
259-
}): Promise<UserApplication | null> {
260-
if (!application) return application
261-
const titleUpdate = resolveTitleUpdate(manifest, application)
262-
if (!titleUpdate) return application
263-
321+
/** Syncs the application title from the manifest to the user application. */
322+
async function syncAppTitle(
323+
application: UserApplication,
324+
titleUpdate: {from: string | null; to: string},
325+
output: DeployAppOptions['output'],
326+
): Promise<UserApplication> {
264327
deployDebug('Updating application title from manifest', titleUpdate)
265328
output.log(
266329
titleUpdate.from

packages/@sanity/cli/src/actions/deploy/deployChecks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import {deployDebug} from './deployDebug.js'
88
import {type DeployFileSummary, listDeploymentFiles} from './listDeploymentFiles.js'
99
import {type DeployFlags} from './types.js'
1010

11-
type DeployCheckStatus = 'fail' | 'pass' | 'skip' | 'warn'
11+
export type DeployCheckStatus = 'fail' | 'pass' | 'skip' | 'warn'
1212

13-
interface DeployCheck {
13+
export interface DeployCheck {
1414
message: string
1515

1616
/** Stable identifier for machine consumers; the message carries the details */

0 commit comments

Comments
 (0)