Skip to content

Commit e69156f

Browse files
committed
Make the generated CI workflow reach every replica
The workflow assumed one server: it loaded one key, appended one known_hosts entry, and said so in the secrets help. A fleet deploy connects to each replica in turn, so a host missing from known_hosts surfaced as an SSH failure partway through the roll — with earlier replicas already on the new release and the failing one left drained. The workflow now verifies every replica is a known host before deploying and fails with the exact ssh-keyscan command to fix it. The secrets help names how many servers the key and known_hosts must cover, and the check is omitted entirely for a single-server workspace.
1 parent bdf7214 commit e69156f

2 files changed

Lines changed: 105 additions & 4 deletions

File tree

src/cli/commands/ci.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { pathExists, ensureDir } from 'fs-extra';
44
import { ExecaError, execa } from 'execa';
55
import { Result, type Result as ResultType } from 'better-result';
66
import { loadConfig } from '../../config/loader.js';
7+
import { resolveServerNames } from '../../domain/servers.js';
78
import {
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

109112
function 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)}`;

tests/unit/ci-github.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,54 @@ describe('ci github', () => {
168168

169169
expect(await readWorkflow(directory)).toContain('run: shipnode deploy');
170170
});
171+
172+
it('checks every replica is a known host before rolling a fleet', async () => {
173+
// A host missing from known_hosts otherwise surfaces as an SSH failure on
174+
// replica three, with one and two already updated and three left drained.
175+
const root = await temporaryDirectory('shipnode-ci-fleet-');
176+
await initialiseGitRepository(root);
177+
await writeFile(join(root, 'package.json'), '{}');
178+
await writeFile(join(root, 'shipnode.config.ts'), `
179+
export default {
180+
servers: {
181+
'web-a': { host: '10.0.0.11', user: 'deploy', privateHost: '10.0.0.11' },
182+
'web-b': { host: '10.0.0.12', user: 'deploy', privateHost: '10.0.0.12' },
183+
},
184+
nodeVersion: '22',
185+
remotePath: '/var/www/app',
186+
apps: [{
187+
name: 'api',
188+
appType: 'backend',
189+
on: ['web-a', 'web-b'],
190+
envFile: '.env',
191+
pm2: { apps: [{ name: 'api', port: 3000 }] },
192+
}],
193+
};
194+
`);
195+
196+
await cmdCiGithub(root, { app: 'api', syncEnv: true });
197+
198+
const workflow = await readWorkflow(root);
199+
expect(() => load(workflow)).not.toThrow();
200+
expect(workflow).toContain('Verify every replica is a known host');
201+
expect(workflow).toContain("for host in '10.0.0.11' '10.0.0.12'");
202+
expect(workflow).toContain('ssh-keyscan 10.0.0.11 10.0.0.12');
203+
});
204+
205+
it('omits the replica check for a single-server workspace', async () => {
206+
const root = await temporaryDirectory('shipnode-ci-solo-');
207+
await initialiseGitRepository(root);
208+
await writeFile(join(root, 'package.json'), '{}');
209+
await writeFile(join(root, 'shipnode.config.ts'), `
210+
export default {
211+
ssh: { host: 'example.com', user: 'deploy' },
212+
nodeVersion: '22',
213+
apps: [{ name: 'api', appType: 'backend', envFile: '.env' }],
214+
};
215+
`);
216+
217+
await cmdCiGithub(root, { app: 'api', syncEnv: true });
218+
219+
expect(await readWorkflow(root)).not.toContain('Verify every replica is a known host');
220+
});
171221
});

0 commit comments

Comments
 (0)