@@ -152,7 +152,8 @@ async function rollFleetApp(
152152 }
153153
154154 const appConfig = scoped . value ;
155- let replicas = getServerTargets ( appConfig ) . map ( ( target ) => target . name ) ;
155+ const allReplicas = getServerTargets ( appConfig ) . map ( ( target ) => target . name ) ;
156+ let replicas = allReplicas ;
156157 if ( options . on ) {
157158 if ( ! replicas . includes ( options . on ) ) {
158159 ui . error ( `App '${ app . name } ' does not run on '${ options . on } '. It runs on: ${ replicas . join ( ', ' ) } ` ) ;
@@ -165,29 +166,26 @@ async function rollFleetApp(
165166 ui . banner ( ) ;
166167 ui . step ( `Rolling ${ chalk . bold ( app . name ) } across ${ replicas . join ( ', ' ) } ` ) ;
167168
168- if ( app . hooks ?. preDeploy ) {
169- ui . warn (
170- `${ app . name } declares a preDeploy hook, which runs once per replica — ` +
171- `${ replicas . length } times for this roll. Database migrations belong somewhere that runs once.` ,
172- ) ;
173- }
174-
169+ for ( const warning of renderFleetWarnings ( app , replicas ) ) ui . warn ( `${ app . name } : ${ warning } ` ) ;
175170 for ( const warning of renderDependencyWarnings ( config , app ) ) ui . warn ( warning ) ;
176171
177172 const result = await deployFleet ( {
178173 app,
179174 fleet : app . fleet ! ,
180175 replicas,
176+ // From the full list, not the narrowed one: `--on web-b` must not promote
177+ // web-b to primary and start a second copy of the scheduler alongside web-a's.
178+ primary : allReplicas [ 0 ] ,
181179 remotePath : appConfig . remotePath ,
182180 connect : async ( serverName ) => {
183181 const ssh = new SshConnection ( ) ;
184182 await ssh . connect ( configForServer ( appConfig , serverName ) . ssh ) ;
185183 return { executor : ssh , close : ( ) => ssh . disconnect ( ) } ;
186184 } ,
187- deployReplica : async ( { serverName, executor, releaseId } ) => {
185+ deployReplica : async ( { serverName, executor, releaseId, role } ) => {
188186 const replicaConfig = { ...configForServer ( appConfig , serverName ) , apps : [ app ] , accessories : { } } ;
189187 const deployer = new DeployService ( new LoggingExecutor ( executor ) , replicaConfig ) ;
190- await deployer . execute ( cwd , options . skipBuild ?? false , releaseId ) ;
188+ await deployer . execute ( cwd , options . skipBuild ?? false , releaseId , role ) ;
191189 } ,
192190 onEvent : ( event ) => reportFleetEvent ( app , event ) ,
193191 } ) ;
@@ -344,11 +342,14 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
344342 serverRows . push ( [
345343 'PM2 apps' ,
346344 pm2Apps
347- . map ( ( a ) =>
348- a . port !== undefined
349- ? `${ getPm2Name ( namespace , a . name ) } (web:${ a . port } )`
350- : getPm2Name ( namespace , a . name ) ,
351- )
345+ . map ( ( a ) => {
346+ const name = getPm2Name ( namespace , a . name ) ;
347+ if ( a . port !== undefined ) return `${ name } (web:${ a . port } )` ;
348+ // Where a pinned worker actually lands is the thing worth checking
349+ // before the first roll — say it rather than leave it implied.
350+ if ( a . placement === 'primary' && replicas . length > 1 ) return `${ name } (${ replicas [ 0 ] } only)` ;
351+ return name ;
352+ } )
352353 . join ( ', ' ) ,
353354 ] ) ;
354355 }
@@ -359,6 +360,7 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
359360 if ( app . dependsOn ?. length ) serverRows . push ( [ 'Depends on' , app . dependsOn . join ( ', ' ) ] ) ;
360361
361362 const dependencyWarnings = renderDependencyWarnings ( config , app ) ;
363+ const fleetWarnings = renderFleetWarnings ( app , replicas ) ;
362364
363365 const caddyPreview = renderCaddyPreview ( config , app ) ;
364366
@@ -386,13 +388,15 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
386388 'Rsync files' ,
387389 'Install dependencies' ,
388390 ) ;
391+ if ( app . hooks ?. beforeFleet ) steps . push ( 'Run beforeFleet hook (first replica only)' ) ;
389392 if ( app . hooks ?. preDeploy ) steps . push ( 'Run preDeploy hook' ) ;
390393 steps . push ( 'Switch symlink (atomic)' ) ;
391394 if ( app . appType === 'backend' ) steps . push ( 'Reload PM2' ) ;
392395 if ( app . healthCheck . enabled ) steps . push ( `Health check ${ app . healthCheck . path } ` ) ;
393- steps . push ( 'Record release' , 'Clean old releases' ) ;
396+ steps . push ( 'Record release' ) ;
394397 if ( app . hooks ?. postDeploy ) steps . push ( 'Run postDeploy hook' ) ;
395- steps . push ( 'Release lock' ) ;
398+ if ( app . hooks ?. afterFleet ) steps . push ( 'Run afterFleet hook (last replica only)' ) ;
399+ steps . push ( 'Clean old releases' , 'Release lock' ) ;
396400 if ( app . fleet ) {
397401 steps . push ( `Undrain (${ app . fleet . readyPath } → 200)` , 'Repeat for the next batch' ) ;
398402 }
@@ -408,11 +412,30 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
408412 '' ,
409413 chalk . bold ( ' Deploy flow' ) ,
410414 ...flowRows . map ( ( [ k , v ] ) => ` ${ chalk . dim ( k . padEnd ( 4 ) ) } ${ v } ` ) ,
415+ ...( fleetWarnings . length ? [ '' , chalk . bold ( ' Fleet hints' ) , ...fleetWarnings . map ( ( line ) => ` ${ line } ` ) ] : [ ] ) ,
411416 ...( dependencyWarnings . length ? [ '' , chalk . bold ( ' Dependency hints' ) , ...dependencyWarnings . map ( ( line ) => ` ${ line } ` ) ] : [ ] ) ,
412417 ...( caddyPreview ? [ '' , chalk . bold ( ' Caddy' ) , caddyPreview . split ( '\n' ) . map ( ( line ) => ` ${ line } ` ) . join ( '\n' ) ] : [ ] ) ,
413418 ] . join ( '\n' ) ;
414419}
415420
421+ /**
422+ * Caveats that only bite once an app has more than one replica, and that the
423+ * config itself cannot decide for you.
424+ */
425+ function renderFleetWarnings ( app : ShipnodeApp , replicas : string [ ] ) : string [ ] {
426+ if ( replicas . length < 2 ) return [ ] ;
427+ const warnings : string [ ] = [ ] ;
428+
429+ if ( app . hooks ?. preDeploy ) {
430+ warnings . push (
431+ `preDeploy runs once per replica — ${ replicas . length } times for this roll. ` +
432+ `Move database migrations to .beforeFleet(), which runs once.` ,
433+ ) ;
434+ }
435+
436+ return warnings ;
437+ }
438+
416439function renderDependencyWarnings ( config : ShipnodeConfig , app : ShipnodeApp ) : string [ ] {
417440 const dependencies = app . dependsOn ?? [ ] ;
418441 if ( dependencies . length === 0 ) return [ ] ;
0 commit comments