Skip to content

Commit f7a29be

Browse files
committed
docs(changelog): 3.0 multi-app changelog + multi-app orchestrator integration test (sprint 2g)
1 parent 8d8a90a commit f7a29be

2 files changed

Lines changed: 127 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,32 @@
22

33
All notable changes to `@devalade/shipnode` will be documented here.
44

5-
## [Unreleased]
5+
## [3.0.0] - unreleased
66

7-
### Changed (internal, towards 3.0)
8-
- **Schema is now the single source of truth for the config shape.** `assembleConfig` no longer enumerates every field into a `withDefaults` object — that pattern was how `.aliases()` got dropped in 2.5.1. The function is now a thin transformer: normalize the legacy `pm2 { name }` input onto canonical `pm2.apps`, then hand the whole partial to `ShipnodeConfigSchema.parse`. New fields added to the schema (and their builder methods) are preserved automatically. Schema defaults moved to the schema itself (`app: 'backend'`, `remotePath: '/var/www/app'`) so they are reachable without going through the assembler.
9-
- **`HooksConfigSchema` uses `z.custom<HookFn>` instead of `z.function()`.** `z.function()` wraps the user's function in a validator, breaking reference equality — `config.hooks.postDeploy === userFn` was false after parse. The custom-with-refine variant validates "is callable" while preserving the reference, which is what the deploy orchestrator needs to invoke the original hook. Aligned with zod 4's deprecation of `z.function()` for value validation.
10-
- **Schema-coverage test (`tests/unit/builder.test.ts`).** Every builder setter is exercised in a single round-trip; the resulting config is asserted to contain each field written by the builder. Prevents future drift between builder, schema, and assembly.
7+
### Added
8+
- **Multi-app workspaces** — a single `shipnode.config.ts` can now declare multiple applications deployed to the same server, each with its own domain, PM2 process set, health check, env file, build steps, and hooks:
9+
- New `.app(name, fn)` builder method to define per-app configuration inside a workspace.
10+
- New `.apps([api, web])` builder method to compose multiple apps into one deployment.
11+
- Each app gets its own release directory (`<remotePath>/<app-name>/releases/<ts>/`), own Caddy site block, own PM2 ecosystem, and own lock file.
12+
- Orchestrator iterates over all apps, selecting the right strategy per-app (backend/frontend).
13+
- **`--app <name>` CLI flag** — target a single app in a multi-app workspace (`shipnode deploy --app api`, `shipnode logs --app web`). Commands without `--app` apply to all apps. `rollback` requires `--app`.
14+
- **`getActiveApp(config, name?)` workspace helper** — selects the right app by name or returns `apps[0]` when called without a name.
15+
16+
### Changed
17+
- **Config shape split: workspace-level vs. per-app fields.** Workspace-level (`remotePath`, `ssh`, `pkgManager`, `aliases`, `nodeVersion`, etc.) stays on the root config. Per-app fields (`domain`, `pm2`, `healthCheck`, `envFile`, `keepReleases`, `buildDir`, `appRoot`, `sharedDirs`, `sharedFiles`, `hooks`) moved into `apps[]`.
18+
- Legacy top-level input fields are still accepted and synthesized onto `apps[0]` via `z.preprocess` — backward compatible.
19+
- **Schema-based assembly**`assembleConfig` no longer enumerates every field manually. The zod schema is the single source of truth; assembly normalizes legacy input and calls `ShipnodeConfigSchema.parse()`. Prevents drift between builder, schema, and assembly (fixes the pattern that dropped `.aliases()` in 2.5.1).
20+
- **`BuilderState` is now a standalone type** with workspace-level and legacy input fields, no longer derives from `ShipnodeConfig` (which no longer has those legacy mirrors).
21+
22+
### Removed
23+
- Legacy top-level mirrors from `ShipnodeConfig` TypeScript shape:
24+
`config.app`, `config.pm2`, `config.domain`, `config.healthCheck`,
25+
`config.envFile`, `config.keepReleases`, `config.buildDir`, `config.appRoot`,
26+
`config.sharedDirs`, `config.sharedFiles`, `config.hooks` — read from `config.apps[i]` instead.
27+
28+
### Internal
29+
- **`HooksConfigSchema` uses `z.custom<HookFn>` instead of `z.function()`** — preserves reference equality so `config.hooks.postDeploy === userFn` is true after parse.
30+
- **Schema-coverage test** (`tests/unit/builder.test.ts`) — every builder setter is exercised in a round-trip; prevents future drift between builder, schema, and assembly.
1131

1232
## [2.5.2] - 2026-06-30
1333

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { DeployOrchestrator } from '../../src/domain/deploy/orchestrator.js';
3+
import { FakeRemoteExecutor } from '../testing/fake-executor.js';
4+
import { assembleConfig } from '../../src/config/assembly.js';
5+
6+
vi.mock('execa', () => ({
7+
execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
8+
}));
9+
10+
vi.mock('fs-extra', () => ({
11+
pathExists: vi.fn().mockResolvedValue(false),
12+
}));
13+
14+
describe('DeployOrchestrator — multi-app', () => {
15+
it('deploys a backend app and a frontend app under the same workspace', async () => {
16+
const executor = new FakeRemoteExecutor();
17+
18+
executor
19+
// mkdir per app
20+
.when((cmd) => cmd.includes('mkdir -p') && cmd.includes('/api/'), { stdout: '', exitCode: 0 })
21+
.when((cmd) => cmd.includes('mkdir -p') && cmd.includes('/web/'), { stdout: '', exitCode: 0 })
22+
// symlink
23+
.when((cmd) => cmd.includes('ln -sfn'), { stdout: '', exitCode: 0 })
24+
// mv
25+
.when((cmd) => cmd.includes('mv -Tf'), { stdout: '', exitCode: 0 })
26+
// releases.json read
27+
.when((cmd) => cmd.includes('cat') && cmd.includes('releases.json'), { stdout: '[]', exitCode: 0 })
28+
// releases.json write
29+
.when((cmd) => cmd.includes('releases.json') && cmd.includes('base64'), { stdout: '', exitCode: 0 })
30+
// ls
31+
.when((cmd) => cmd.includes('ls -1t'), { stdout: '', exitCode: 0 })
32+
// lock
33+
.when((cmd) => cmd.includes('deploy.lock'), { stdout: 'OK', exitCode: 0 })
34+
// health check on the backend
35+
.when((cmd) => cmd.includes('curl') && cmd.includes('localhost:3000'), { stdout: '200 42', exitCode: 0 })
36+
// pm2 jlist — both apps must be online
37+
.when((cmd) => cmd.includes('pm2 jlist'), {
38+
stdout: JSON.stringify([
39+
{ name: 'api', pm2_env: { status: 'online', restart_time: 0 } },
40+
]),
41+
exitCode: 0,
42+
});
43+
44+
const config = assembleConfig({
45+
apps: [
46+
{
47+
name: 'api',
48+
appType: 'backend',
49+
domain: 'api.example.com',
50+
pm2: { apps: [{ name: 'api', port: 3000 }] },
51+
healthCheck: { enabled: true, path: '/health', timeout: 30, retries: 3, startupDelay: 0 },
52+
envFile: '.env.api',
53+
keepReleases: 5,
54+
},
55+
{
56+
name: 'web',
57+
appType: 'frontend',
58+
domain: 'www.example.com',
59+
keepReleases: 3,
60+
},
61+
],
62+
ssh: { host: '1.2.3.4', user: 'deploy', port: 22 },
63+
remotePath: '/var/www/app',
64+
});
65+
66+
const { DeployLock } = await import('../../src/domain/release/manager.js');
67+
const { HealthCheckService } = await import('../../src/services/health.service.js');
68+
const { CaddyService } = await import('../../src/services/caddy.service.js');
69+
70+
const orchestrator = new DeployOrchestrator(
71+
config,
72+
executor,
73+
new DeployLock(executor, config.remotePath),
74+
new HealthCheckService(executor, config),
75+
new CaddyService(executor, config),
76+
);
77+
78+
await orchestrator.deploy({ cwd: '/test', skipBuild: false });
79+
80+
const history = executor.getHistory();
81+
82+
// Lock was acquired
83+
const lockAcquire = history.find((h) => h.command.includes('deploy.lock'));
84+
expect(lockAcquire).toBeDefined();
85+
86+
// Lock was released
87+
const lockRelease = history.filter((h) => h.command.includes('rm -f') && h.command.includes('deploy.lock'));
88+
expect(lockRelease.length).toBeGreaterThan(0);
89+
90+
// Backend release dir was created
91+
const apiMkdir = history.filter((h) => h.command.includes('mkdir -p') && h.command.includes('/api/'));
92+
expect(apiMkdir.length).toBeGreaterThan(0);
93+
94+
// Frontend release dir was created
95+
const webMkdir = history.filter((h) => h.command.includes('mkdir -p') && h.command.includes('/web/'));
96+
expect(webMkdir.length).toBeGreaterThan(0);
97+
98+
// Health check ran for the backend (frontend has none)
99+
const health = history.filter((h) => h.command.includes('curl') && h.command.includes('localhost:3000'));
100+
expect(health.length).toBeGreaterThan(0);
101+
});
102+
});

0 commit comments

Comments
 (0)