Skip to content

Commit 9663058

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 runs the same createStudioDeployment / createAppDeployment sequence read-only: target resolution returns a verdict instead of prompting or creating, 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, with the exit code set without throwing so the full report still reaches stdout on failure.
1 parent 2a4579f commit 9663058

12 files changed

Lines changed: 969 additions & 89 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: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,36 +21,62 @@ import {extractCoreAppManifest, resolveTitleUpdate} from '../manifest/extractCor
2121
import {type CoreAppManifest} from '../manifest/types.js'
2222
import {createUserApplicationForApp} from './createUserApplication.js'
2323
import {
24+
checkAppTarget,
2425
checkAutoUpdates,
2526
checkBuild,
27+
checkOutputDir,
2628
checkPackageVersion,
29+
createAggregatingChecks,
2730
createFailFastChecks,
2831
type DeployChecks,
32+
type DeployTarget,
2933
verifyOutputDir,
3034
} from './deployChecks.js'
3135
import {deployDebug} from './deployDebug.js'
36+
import {type DryRunReport, isDeployable} from './dryRunReport.js'
3237
import {findUserApplicationForApp} from './findUserApplication.js'
33-
import {type DeployAppOptions} from './types.js'
38+
import {type DeployFileSummary} from './listDeploymentFiles.js'
39+
import {type DeployAppOptions, type DeployFlags} from './types.js'
3440

3541
type Workbench = ReturnType<typeof getWorkbench>
3642

3743
/**
38-
* Builds and deploys a Sanity application.
44+
* Builds and deploys a Sanity application. With --dry-run, runs the same
45+
* sequence read-only and returns a report instead of shipping.
3946
*
4047
* @internal
4148
*/
42-
export async function deployApp(options: DeployAppOptions): Promise<void> {
43-
const {cliConfig, output} = options
49+
export async function deployApp(options: DeployAppOptions): Promise<DryRunReport | undefined> {
50+
const {cliConfig, flags, output} = options
4451
const workbench = getWorkbench(cliConfig)
4552

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 createAppDeployment(options, checks, workbench)
63+
return {
64+
checks: checks.all(),
65+
deployable: isDeployable(checks.all()),
66+
dryRun: true,
67+
files,
68+
target,
69+
}
70+
}
71+
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.
4874
if (workbench) {
4975
try {
5076
workbench.assertDeployable()
5177
} catch (err) {
5278
output.error(getErrorMessage(err), {exit: exitCodes.USAGE_ERROR})
53-
return
79+
return undefined
5480
}
5581
}
5682

@@ -61,28 +87,32 @@ export async function deployApp(options: DeployAppOptions): Promise<void> {
6187
// Don't throw a generic error when the user cancels a prompt
6288
if (error.name === 'ExitPromptError') {
6389
output.error('Deployment cancelled by user', {exit: 1})
64-
return
90+
return undefined
6591
}
6692
if (error instanceof CLIError) {
6793
const {message, ...errorOptions} = error
6894
output.error(message, {...errorOptions, exit: 1})
69-
return
95+
return undefined
7096
}
7197
deployDebug('Error deploying application', error)
7298
output.error(`Error deploying application: ${error}`, {exit: 1})
7399
}
100+
return undefined
74101
}
75102

76103
interface AppDeployment {
77104
application: UserApplication | null
105+
files: DeployFileSummary | null
78106
isAutoUpdating: boolean
79107
manifest: CoreAppManifest | undefined
108+
target: DeployTarget | null
80109
version: string | null
81110
}
82111

83112
/**
84113
* Validates the deploy, syncs the title from the manifest, and ships the build.
85-
* Steps report through `checks`; a real deploy fails fast on the first problem.
114+
* Steps report through `checks` (fail fast for real deploys, aggregated for dry
115+
* runs); side-effecting steps and the upload branch on `--dry-run`.
86116
*/
87117
async function createAppDeployment(
88118
options: DeployAppOptions,
@@ -92,6 +122,7 @@ async function createAppDeployment(
92122
const {cliConfig, flags, output, projectRoot, sourceDir} = options
93123
const workDir = projectRoot.directory
94124
const organizationId = cliConfig.app?.organizationId
125+
const dryRun = !!flags['dry-run']
95126

96127
const isAutoUpdating = checkAutoUpdates(checks, {cliConfig, flags})
97128

@@ -108,8 +139,14 @@ async function createAppDeployment(
108139
)
109140

110141
let application: UserApplication | null = null
142+
let target: DeployTarget | null = null
111143
if (flags.external) {
112144
checks.add({message: EXTERNAL_APP_NOT_SUPPORTED, name: 'target', status: 'fail'})
145+
} else if (dryRun) {
146+
;({existingApp: application, target} = await checkAppTarget(checks, {
147+
appId: getAppId(cliConfig),
148+
organizationId,
149+
}))
113150
} else {
114151
application = await resolveAppApplication(options)
115152
}
@@ -120,7 +157,8 @@ async function createAppDeployment(
120157
autoUpdatesEnabled: isAutoUpdating,
121158
calledFromDeploy: true,
122159
cliConfig,
123-
flags,
160+
// Dry runs never prompt
161+
flags: dryRun ? ({...flags, yes: true} as DeployFlags) : flags,
124162
outDir: sourceDir,
125163
output,
126164
workDir,
@@ -131,10 +169,16 @@ async function createAppDeployment(
131169
successMessage: 'App built',
132170
})
133171

134-
await verifyOutputDir({output, sourceDir, workbench})
172+
let files: DeployFileSummary | null = null
173+
if (dryRun) {
174+
files = await checkOutputDir(checks, {sourceDir, workbench})
175+
} else {
176+
await verifyOutputDir({output, sourceDir, workbench})
177+
}
135178

136179
// Manifests aren't strictly essential, so a failure warns and continues
137180
let manifest: CoreAppManifest | undefined
181+
let manifestFailed = false
138182
try {
139183
manifest = await extractCoreAppManifest({workDir})
140184
} catch (err) {
@@ -144,11 +188,26 @@ async function createAppDeployment(
144188
name: 'app-manifest',
145189
status: 'warn',
146190
})
191+
manifestFailed = true
147192
}
148193

149194
// Sync the application title from the manifest when it has changed
150195
const titleUpdate = application ? resolveTitleUpdate(manifest, application) : null
151-
if (application && titleUpdate) {
196+
if (dryRun) {
197+
if (!manifestFailed) {
198+
checks.add({
199+
message: titleUpdate
200+
? titleUpdate.from
201+
? `Would update application title from "${titleUpdate.from}" to "${titleUpdate.to}"`
202+
: `Would set application title to "${titleUpdate.to}"`
203+
: manifest
204+
? 'App manifest extracted'
205+
: 'No app manifest (no icon or title in app configuration)',
206+
name: 'app-manifest',
207+
status: 'pass',
208+
})
209+
}
210+
} else if (application && titleUpdate) {
152211
deployDebug('Updating application title from manifest', titleUpdate)
153212
output.log(
154213
titleUpdate.from
@@ -171,7 +230,9 @@ async function createAppDeployment(
171230
}
172231
}
173232

174-
if (!application || !version) return {application, isAutoUpdating, manifest, version}
233+
if (dryRun || !application || !version) {
234+
return {application, files, isAutoUpdating, manifest, target, version}
235+
}
175236

176237
const parentDir = dirname(sourceDir)
177238
const base = basename(sourceDir)
@@ -192,7 +253,7 @@ async function createAppDeployment(
192253
}
193254
} catch (err) {
194255
output.error(`Invalid view declaration: ${getErrorMessage(err)}`, {exit: 1})
195-
return {application, isAutoUpdating, manifest, version}
256+
return {application, files, isAutoUpdating, manifest, target, version}
196257
}
197258
}
198259

@@ -234,7 +295,7 @@ ${styleText(
234295
)}`)
235296
}
236297

237-
return {application, isAutoUpdating, manifest, version}
298+
return {application, files, isAutoUpdating, manifest, target, version}
238299
}
239300

240301
/** Resolves the app's target application, creating one when none exists. */

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import {getErrorMessage} from '../../util/getErrorMessage.js'
1010
import {getAutoUpdateIssueMessage, resolveAutoUpdates} from '../build/shouldAutoUpdate.js'
1111
import {checkDir} from './checkDir.js'
1212
import {deployDebug} from './deployDebug.js'
13+
import {type DeployFileSummary, listDeploymentFiles} from './listDeploymentFiles.js'
1314
import {resolveAppDeployTarget, resolveStudioDeployTarget} from './resolveDeployTarget.js'
1415
import {type DeployFlags} from './types.js'
1516

16-
type DeployCheckStatus = 'fail' | 'pass' | 'skip' | 'warn'
17+
export type DeployCheckStatus = 'fail' | 'pass' | 'skip' | 'warn'
1718

18-
interface DeployCheck {
19+
export interface DeployCheck {
1920
message: string
2021

2122
/** Stable identifier for machine consumers; the message carries the details */
@@ -168,6 +169,39 @@ export async function verifyOutputDir({
168169
}
169170
}
170171

172+
/**
173+
* Validates the output directory and lists the files a deploy would upload,
174+
* reporting through `checks` instead of a spinner.
175+
*/
176+
export async function checkOutputDir(
177+
checks: DeployChecks,
178+
{
179+
skipReason,
180+
sourceDir,
181+
workbench,
182+
}: {
183+
skipReason?: string
184+
sourceDir: string
185+
workbench: {checkBuiltOutput(sourceDir: string): Promise<void>} | null
186+
},
187+
): Promise<DeployFileSummary | null> {
188+
if (skipReason) {
189+
checks.add({message: skipReason, name: 'output-dir', status: 'skip'})
190+
return null
191+
}
192+
193+
return checks.run('output-dir', async () => {
194+
await (workbench ? workbench.checkBuiltOutput(sourceDir) : checkDir(sourceDir))
195+
checks.add({message: `Output directory: ${sourceDir}`, name: 'output-dir', status: 'pass'})
196+
const list = await listDeploymentFiles(sourceDir)
197+
return {
198+
count: list.length,
199+
list,
200+
totalBytes: list.reduce((total, file) => total + file.size, 0),
201+
}
202+
})
203+
}
204+
171205
export interface DeployTarget {
172206
/** User application id when the deploy targets an existing application */
173207
appId: string | null

0 commit comments

Comments
 (0)