@@ -4,6 +4,7 @@ import { pathExists, ensureDir } from 'fs-extra';
44import { ExecaError , execa } from 'execa' ;
55import { Result , type Result as ResultType } from 'better-result' ;
66import { loadConfig } from '../../config/loader.js' ;
7+ import { resolveServerNames } from '../../domain/servers.js' ;
78import {
89 CiAppTargetRequiredError ,
910 CiConfigLoadError ,
@@ -104,6 +105,8 @@ interface WorkflowEnvironmentTarget {
104105 readonly appName : string ;
105106 readonly envFile : string ;
106107 readonly nodeVersion : string ;
108+ /** Every SSH host this workflow's deploy will connect to. */
109+ readonly hosts : string [ ] ;
107110}
108111
109112function renderCommandOptions ( options : CiGithubOptions ) : string {
@@ -134,10 +137,21 @@ async function resolveWorkflowEnvironmentTarget(
134137 return Result . err ( new UnknownAppError ( { name : options . app ?? '(default)' } ) ) ;
135138 }
136139
140+ // Every replica, not just config.ssh: a fleet deploy connects to each in
141+ // turn, and a host missing from known_hosts fails mid-roll with replicas
142+ // already drained.
143+ const hosts = [ ...new Set (
144+ resolveServerNames ( configResult . value , app . on ) . flatMap ( ( name ) => {
145+ const server = configResult . value . servers [ name ] ;
146+ return server ? [ server . host ] : [ ] ;
147+ } ) ,
148+ ) ] ;
149+
137150 return Result . ok ( {
138151 appName : app . name ,
139152 envFile : app . envFile ,
140153 nodeVersion : configResult . value . nodeVersion === 'lts' ? '24' : configResult . value . nodeVersion ,
154+ hosts : hosts . length > 0 ? hosts : [ configResult . value . ssh . host ] ,
141155 } ) ;
142156}
143157
@@ -148,6 +162,7 @@ function generateWorkflow(
148162 options : CiGithubOptions = { } ,
149163 envFile ?: string ,
150164 nodeVersion = '20' ,
165+ hosts : string [ ] = [ ] ,
151166) : string {
152167 const environment = options . environment ?? 'production' ;
153168 const concurrencySuffix = options . app
@@ -161,6 +176,22 @@ function generateWorkflow(
161176 ? `\n working-directory: ${ yamlSingleQuoted ( deployDirectory ) } `
162177 : '' ;
163178 const commandOptions = renderCommandOptions ( options ) ;
179+
180+ const knownHostsScan = hosts . length > 0 ? hosts . join ( ' ' ) : '<your-server>' ;
181+ // Fail before the roll rather than during it. A host missing from
182+ // known_hosts otherwise surfaces as an SSH failure on replica three, with
183+ // replicas one and two already updated and replica three drained.
184+ const knownHostsCheck = hosts . length < 2 ? '' : `
185+ - name: Verify every replica is a known host
186+ run: |
187+ for host in ${ hosts . map ( ( h ) => shellSingleQuoted ( h ) ) . join ( ' ' ) } ; do
188+ ssh-keygen -F "$host" >/dev/null || {
189+ echo "::error::$host is missing from SHIPNODE_KNOWN_HOSTS. Rebuild it with: ssh-keyscan ${ knownHostsScan } "
190+ exit 1
191+ }
192+ done
193+ ` ;
194+
164195 const envSyncSteps = options . syncEnv && envFile && options . app
165196 ? ( ( ) => {
166197 const secretName = `SHIPNODE_ENV_${ githubSecretSegment ( environment ) } _${ githubSecretSegment ( options . app ) } ` ;
@@ -223,16 +254,20 @@ ${setupPmStep} - name: Setup Node.js
223254 - name: Install dependencies
224255 run: ${ pm . installCmd }
225256
257+ # SHIPNODE_SSH_KEY may hold several keys, one after another, when the
258+ # servers below do not share one. ssh-agent loads each in turn.
226259 - name: Setup SSH agent
227260 uses: webfactory/ssh-agent@v0.9.0
228261 with:
229262 ssh-private-key: \${{ secrets.SHIPNODE_SSH_KEY }}
230263
231- - name: Add server to known hosts
264+ # SHIPNODE_KNOWN_HOSTS must cover every server this deploy reaches. Build
265+ # it with: ssh-keyscan ${ knownHostsScan }
266+ - name: Add servers to known hosts
232267 run: |
233268 mkdir -p ~/.ssh
234269 echo "\${{ secrets.SHIPNODE_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
235-
270+ ${ knownHostsCheck }
236271 - name: Install Shipnode
237272 run: npm install -g ${ packageSpec }
238273${ envSyncSteps }
@@ -265,6 +300,7 @@ export async function cmdCiGithub(
265300 let workflowOptions = options ;
266301 let envFile : string | undefined ;
267302 let nodeVersion = '20' ;
303+ let hosts : string [ ] = [ ] ;
268304 if ( options . syncEnv ) {
269305 const target = await resolveWorkflowEnvironmentTarget ( canonicalCwd , options ) ;
270306 if ( target . isErr ( ) ) {
@@ -275,6 +311,7 @@ export async function cmdCiGithub(
275311 workflowOptions = { ...options , app : target . value . appName } ;
276312 envFile = target . value . envFile ;
277313 nodeVersion = target . value . nodeVersion ;
314+ hosts = target . value . hosts ;
278315 } else {
279316 const configFile = resolve ( canonicalCwd , options . config ?? 'shipnode.config.ts' ) ;
280317 if ( await pathExists ( configFile ) ) {
@@ -288,6 +325,7 @@ export async function cmdCiGithub(
288325 return ;
289326 }
290327 nodeVersion = configResult . value . nodeVersion === 'lts' ? '24' : configResult . value . nodeVersion ;
328+ hosts = [ ...new Set ( Object . values ( configResult . value . servers ) . map ( ( server ) => server . host ) ) ] ;
291329 }
292330 }
293331
@@ -312,16 +350,29 @@ export async function cmdCiGithub(
312350 workflowOptions ,
313351 envFile ,
314352 nodeVersion ,
353+ hosts ,
315354 ) ;
316355 await writeFile ( workflowPath , content , 'utf8' ) ;
317356
318357 ui . success ( `Workflow written to .github/workflows/shipnode-deploy.yml` ) ;
319358 ui . heading ( 'Required GitHub Secrets' ) ;
320359 console . log ( ' Repository Actions secrets:' ) ;
321360 console . log ( '' ) ;
322- console . log ( ' SHIPNODE_SSH_KEY Your SSH private key for server access' ) ;
323- console . log ( ' SHIPNODE_KNOWN_HOSTS Known hosts entry for your server' ) ;
361+ const plural = hosts . length > 1 ;
362+ console . log (
363+ plural
364+ ? ` SHIPNODE_SSH_KEY Private key(s) for all ${ hosts . length } servers, one after another`
365+ : ' SHIPNODE_SSH_KEY Your SSH private key for server access' ,
366+ ) ;
367+ console . log (
368+ plural
369+ ? ` SHIPNODE_KNOWN_HOSTS Known hosts entries for all ${ hosts . length } servers`
370+ : ' SHIPNODE_KNOWN_HOSTS Known hosts entry for your server' ,
371+ ) ;
324372 console . log ( ' (capture outside CI and verify the host fingerprint)' ) ;
373+ if ( hosts . length > 0 ) {
374+ console . log ( ` ssh-keyscan ${ hosts . join ( ' ' ) } ` ) ;
375+ }
325376 if ( workflowOptions . syncEnv && workflowOptions . app ) {
326377 const environment = workflowOptions . environment ?? 'production' ;
327378 const secretName = `SHIPNODE_ENV_${ githubSecretSegment ( environment ) } _${ githubSecretSegment ( workflowOptions . app ) } ` ;
0 commit comments