anton-bakker-deploy-engine 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,52 @@
1
1
  # anton-bakker-deploy-engine
2
2
 
3
- Self-validating AWS deployment verification with a 4-layer model: **exists → configured → functional → project-specific**.
4
-
5
- Zero runtime dependencies. TypeScript. ESM.
3
+ A self-validating AWS deployment verification library with a 4-layer verification model and a config-driven pipeline engine.
4
+
5
+ Zero runtime dependencies. TypeScript. ESM. Node.js ≥ 20.
6
+
7
+ ## Table of Contents
8
+
9
+ - [Overview](#overview)
10
+ - [Installation](#installation)
11
+ - [Architecture](#architecture)
12
+ - [Verification Functions](#verification-functions)
13
+ - [verifyAmplifyOutputs](#verifyamplifyoutputs)
14
+ - [verifyBuildArtifact](#verifybuildartifact)
15
+ - [parseTableCount](#parsetablecount)
16
+ - [assertTableCount](#asserttablecount)
17
+ - [parseEcsStatus](#parseecsstatus)
18
+ - [assertCognitoGroups](#assertcognitogroups)
19
+ - [assertApiHealth](#assertapihealth)
20
+ - [deriveExpectations](#deriveexpectations)
21
+ - [summarize](#summarize)
22
+ - [Pipeline Engine](#pipeline-engine)
23
+ - [Concepts](#concepts)
24
+ - [Building Gates](#building-gates)
25
+ - [Writing Check Executors](#writing-check-executors)
26
+ - [Creating the Execution Context](#creating-the-execution-context)
27
+ - [Running the Pipeline](#running-the-pipeline)
28
+ - [State Persistence and Resume](#state-persistence-and-resume)
29
+ - [Clearing State](#clearing-state)
30
+ - [Default Environments](#default-environments)
31
+ - [Types Reference](#types-reference)
32
+ - [Publishing](#publishing)
33
+ - [Development](#development)
34
+ - [Licence](#licence)
35
+
36
+ ## Overview
37
+
38
+ Every AWS deployment can fail silently — tables missing, ECS tasks crashing, environment variables leaking into production builds. This library provides composable verification functions and a pipeline engine that catches these problems automatically.
39
+
40
+ The 4-layer verification model:
41
+
42
+ | Layer | Question | Examples |
43
+ |-------|----------|---------|
44
+ | **Exists** | Is the resource present? | amplify_outputs.json readable, index JS bundle exists |
45
+ | **Configured** | Is it set up correctly? | Version is 1.4, data.url is HTTPS, no localhost leaks |
46
+ | **Functional** | Is it working at runtime? | ECS service healthy, API responds, DynamoDB tables present |
47
+ | **Project-specific** | Does it match our source of truth? | Table count matches schema, Cognito groups match auth stack |
48
+
49
+ You can use the verification functions standalone (for scripts, CI checks, post-deploy validation) or wire them into the pipeline engine for a full gated deployment workflow.
6
50
 
7
51
  ## Installation
8
52
 
@@ -10,125 +54,764 @@ Zero runtime dependencies. TypeScript. ESM.
10
54
  npm install anton-bakker-deploy-engine
11
55
  ```
12
56
 
13
- Requires Node.js ≥ 20.
57
+ Requires Node.js ≥ 20. Zero runtime dependencies — only uses Node.js built-ins (`fs`, `path`).
58
+
59
+ ## Architecture
14
60
 
15
- ## Quick Start
61
+ ```
62
+ src/
63
+ ├── types.ts # All type definitions (CheckResult, GateDefinition, etc.)
64
+ ├── verify.ts # Standalone verification functions (Layer 1–4 checks)
65
+ ├── pipeline.ts # Gate builder + default environment configs
66
+ ├── engine.ts # PipelineEngine class (gate executor, state, resume)
67
+ └── index.ts # Public API — all exports
68
+ ```
16
69
 
17
- ### Verification Functions (Layer 1–3)
70
+ Everything is exported from the package root:
18
71
 
19
72
  ```typescript
20
73
  import {
21
- verifyAmplifyOutputs,
22
- verifyBuildArtifact,
23
- parseTableCount,
24
- assertTableCount,
25
- parseEcsStatus,
26
- assertCognitoGroups,
27
- assertApiHealth,
28
- deriveExpectations,
29
- summarize,
74
+ // Verification functions
75
+ verifyAmplifyOutputs, verifyBuildArtifact, parseTableCount,
76
+ assertTableCount, parseEcsStatus, assertCognitoGroups,
77
+ assertApiHealth, deriveExpectations, summarize,
78
+ // Pipeline engine
79
+ PipelineEngine, buildGates, DEFAULT_ENVIRONMENTS,
80
+ // Types
81
+ type CheckResult, type GateDefinition, type GateResult,
82
+ type EnvironmentConfig, type PipelineState, type PipelineReport,
83
+ type CheckExecutor, type ExecutionContext,
30
84
  } from 'anton-bakker-deploy-engine';
85
+ ```
31
86
 
32
- // Post-build: validate amplify_outputs.json
33
- const outputChecks = verifyAmplifyOutputs('./amplify_outputs.json');
87
+ ## Verification Functions
34
88
 
35
- // Post-build: scan for environment leaks
36
- const buildChecks = verifyBuildArtifact('./dist');
89
+ All verification functions return `CheckResult` or `CheckResult[]`. Every result has:
37
90
 
38
- // Post-deploy: verify DynamoDB tables
39
- const { count } = parseTableCount(awsCliOutput, 's20-dev');
40
- const tableCheck = assertTableCount(count, 124);
91
+ ```typescript
92
+ interface CheckResult {
93
+ name: string; // Human-readable check description
94
+ passed: boolean; // Did it pass?
95
+ expected?: string | number; // What was expected
96
+ actual?: string | number; // What was found
97
+ duration?: number; // Milliseconds (set by pipeline engine)
98
+ error?: string; // Error message when passed === false
99
+ }
100
+ ```
41
101
 
42
- // Post-deploy: verify ECS health
43
- const ecsCheck = parseEcsStatus(ecsJsonOutput);
102
+ ### verifyAmplifyOutputs
44
103
 
45
- // Aggregate results
46
- const result = summarize([...outputChecks, ...buildChecks, tableCheck, ecsCheck]);
47
- console.log(result.passed ? '✅ All checks passed' : `❌ ${result.failed} checks failed`);
104
+ Validates that `amplify_outputs.json` has all required fields for a working Amplify deployment.
105
+
106
+ ```typescript
107
+ function verifyAmplifyOutputs(outputsPath: string): CheckResult[]
48
108
  ```
49
109
 
50
- ### Pipeline Engine
110
+ **Checks performed:**
111
+ - `version` field is present and equals `"1.4"`
112
+ - `data.url` is present and starts with `https://`
113
+ - `auth.user_pool_id` is present
114
+ - `auth.user_pool_client_id` is present
115
+
116
+ **Example:**
51
117
 
52
118
  ```typescript
53
- import { PipelineEngine, buildGates, DEFAULT_ENVIRONMENTS } from 'anton-bakker-deploy-engine';
54
- import type { CheckExecutor, ExecutionContext } from 'anton-bakker-deploy-engine';
119
+ const checks = verifyAmplifyOutputs('./amplify_outputs.json');
120
+ // Returns 6 CheckResults
55
121
 
56
- // Register check executors
57
- const checks = new Map<string, CheckExecutor>();
58
- checks.set('typecheck', async (ctx) => {
59
- const result = ctx.exec('npx tsc --noEmit');
60
- return { name: 'TypeScript', passed: result !== null };
61
- });
62
- checks.set('api-health', async (ctx) => {
63
- const response = ctx.exec(`curl -s https://${ctx.prefix}.example.com/health`);
64
- return { name: 'API Health', passed: !!response?.includes('__typename') };
122
+ // All passed?
123
+ if (checks.every(c => c.passed)) {
124
+ console.log('amplify_outputs.json is valid');
125
+ }
126
+
127
+ // Find failures
128
+ const failures = checks.filter(c => !c.passed);
129
+ failures.forEach(f => console.error(`${f.name}: ${f.error}`));
130
+ ```
131
+
132
+ **Error handling:** If the file doesn't exist or contains invalid JSON, returns a single failed `CheckResult` with the error message.
133
+
134
+ ### verifyBuildArtifact
135
+
136
+ Scans the built JavaScript bundle for environment leaks that should never reach production.
137
+
138
+ ```typescript
139
+ function verifyBuildArtifact(distDir: string): CheckResult[]
140
+ ```
141
+
142
+ **Parameters:**
143
+ - `distDir` — path to the build output directory (expects `dist/assets/index-*.js`)
144
+
145
+ **Checks performed:**
146
+ - No `localhost:4000` in the bundle (GraphQL sandbox leak)
147
+ - No `localhost:5173` in the bundle (Vite dev server leak)
148
+ - No `http://...elb.amazonaws.com` URLs (insecure ALB URLs)
149
+
150
+ **Example:**
151
+
152
+ ```typescript
153
+ const checks = verifyBuildArtifact('./dist');
154
+ const leaks = checks.filter(c => !c.passed);
155
+ if (leaks.length > 0) {
156
+ console.error('Environment leaks detected in build:');
157
+ leaks.forEach(l => console.error(` ${l.name}: ${l.actual}`));
158
+ process.exit(1);
159
+ }
160
+ ```
161
+
162
+ **Error handling:** Returns a failed result if `dist/assets` doesn't exist or no `index-*.js` file is found.
163
+
164
+ ### parseTableCount
165
+
166
+ Parses DynamoDB table count from AWS CLI output, filtering by a table name prefix.
167
+
168
+ ```typescript
169
+ function parseTableCount(
170
+ awsOutput: string | null,
171
+ prefix: string
172
+ ): CheckResult & { count: number }
173
+ ```
174
+
175
+ **Parameters:**
176
+ - `awsOutput` — raw output from `aws dynamodb list-tables` (JSON array of table names) or a plain number string
177
+ - `prefix` — table name prefix to filter by (e.g. `"s20-dev"`)
178
+
179
+ **Example:**
180
+
181
+ ```typescript
182
+ import { execSync } from 'child_process';
183
+
184
+ const output = execSync('aws dynamodb list-tables --output json --query TableNames').toString();
185
+ const result = parseTableCount(output, 's20-dev');
186
+ console.log(`Found ${result.count} tables with prefix s20-dev`);
187
+ ```
188
+
189
+ **Accepts two input formats:**
190
+ 1. JSON array: `["s20-dev-Employee", "s20-dev-Division", "other-table"]` → filters by prefix, returns count
191
+ 2. Plain number: `"124\n"` → parses as integer
192
+
193
+ ### assertTableCount
194
+
195
+ Verifies the actual table count meets an expected minimum.
196
+
197
+ ```typescript
198
+ function assertTableCount(actual: number, expected: number): CheckResult
199
+ ```
200
+
201
+ **Example:**
202
+
203
+ ```typescript
204
+ const { count } = parseTableCount(awsOutput, 's20-dev');
205
+ const check = assertTableCount(count, 124);
206
+ if (!check.passed) {
207
+ console.error(check.error); // "4 tables missing"
208
+ }
209
+ ```
210
+
211
+ ### parseEcsStatus
212
+
213
+ Parses ECS service health from AWS CLI JSON output.
214
+
215
+ ```typescript
216
+ function parseEcsStatus(awsOutput: string | null): CheckResult
217
+ ```
218
+
219
+ **Parameters:**
220
+ - `awsOutput` — JSON string with `status`, `running`/`runningCount`, and `desired`/`desiredCount` fields
221
+
222
+ **Passes when:** `status === 'ACTIVE'` AND `running >= desired` AND `desired > 0`
223
+
224
+ **Example:**
225
+
226
+ ```typescript
227
+ const output = execSync(`aws ecs describe-services \
228
+ --cluster my-cluster --services my-service \
229
+ --query 'services[0].{status:status,running:runningCount,desired:desiredCount}'`
230
+ ).toString();
231
+
232
+ const check = parseEcsStatus(output);
233
+ if (!check.passed) {
234
+ console.error(check.error); // "2 tasks not running"
235
+ }
236
+ ```
237
+
238
+ **Accepts both field name styles:** `running`/`desired` and `runningCount`/`desiredCount`.
239
+
240
+ ### assertCognitoGroups
241
+
242
+ Verifies that all required Cognito user pool groups exist.
243
+
244
+ ```typescript
245
+ function assertCognitoGroups(
246
+ actualGroups: string[],
247
+ requiredGroups: string[]
248
+ ): CheckResult
249
+ ```
250
+
251
+ **Example:**
252
+
253
+ ```typescript
254
+ const output = execSync(`aws cognito-idp list-groups \
255
+ --user-pool-id eu-central-1_ABC \
256
+ --query 'Groups[].GroupName' --output json`
257
+ ).toString();
258
+ const actual = JSON.parse(output);
259
+
260
+ const check = assertCognitoGroups(actual, ['administrators', 'managers', 'root']);
261
+ if (!check.passed) {
262
+ console.error(check.error); // "Missing: managers, root"
263
+ }
264
+ ```
265
+
266
+ ### assertApiHealth
267
+
268
+ Verifies an API health check response contains `__typename` (GraphQL introspection indicator).
269
+
270
+ ```typescript
271
+ function assertApiHealth(response: string | null): CheckResult
272
+ ```
273
+
274
+ **Example:**
275
+
276
+ ```typescript
277
+ const response = execSync('curl -s https://api.example.com/graphql -d \'{"query":"{__typename}"}\'').toString();
278
+ const check = assertApiHealth(response);
279
+ ```
280
+
281
+ ### deriveExpectations
282
+
283
+ Derives expected deployment values from project source-of-truth files. Reads a GraphQL schema to count model types (which become DynamoDB tables), parses an auth stack for Cognito group definitions, and reads infrastructure config for the API domain.
284
+
285
+ ```typescript
286
+ function deriveExpectations(opts: {
287
+ schemaPath: string; // Path to GraphQL schema file
288
+ authStackPath?: string; // Path to CDK auth stack (optional)
289
+ configPath: string; // Path to infrastructure config JSON
290
+ }): DeployExpectations
291
+ ```
292
+
293
+ **Returns:**
294
+
295
+ ```typescript
296
+ interface DeployExpectations {
297
+ tableCount: number; // Number of model types (= expected DynamoDB tables)
298
+ tableNames: string[]; // Model type names
299
+ cognitoGroups: string[]; // Group names found in auth stack
300
+ apiDomain: string; // Constructed from config dns.apiSubdomain + dns.zoneName
301
+ }
302
+ ```
303
+
304
+ **Schema parsing rules:**
305
+ - Counts all `type X {` definitions in the GraphQL schema
306
+ - Excludes: `Query`, `Mutation`, `Subscription`, types ending in `Input`, types ending in `Connection`
307
+
308
+ **Auth stack parsing:** Finds Cognito group names via two patterns:
309
+ - Direct assignment: `groupName: 'administrators'`
310
+ - Loop pattern: `for (... of ['administrators', 'managers', 'root'])`
311
+
312
+ **Example:**
313
+
314
+ ```typescript
315
+ const expectations = deriveExpectations({
316
+ schemaPath: 'server/src/schema/schema.graphql',
317
+ authStackPath: 'cdk/lib/auth-stack.ts',
318
+ configPath: 'cdk/config/infrastructure.json',
65
319
  });
66
320
 
67
- // Build gates from environment config
68
- const envConfig = DEFAULT_ENVIRONMENTS.development;
321
+ console.log(`Expecting ${expectations.tableCount} DynamoDB tables`);
322
+ console.log(`Required Cognito groups: ${expectations.cognitoGroups.join(', ')}`);
323
+ console.log(`API domain: ${expectations.apiDomain}`);
324
+
325
+ // Use with assertTableCount
326
+ const tableCheck = assertTableCount(actualCount, expectations.tableCount);
327
+ ```
328
+
329
+ **Config file format expected:**
330
+
331
+ ```json
332
+ {
333
+ "dns": {
334
+ "apiSubdomain": "api",
335
+ "zoneName": "example.com"
336
+ }
337
+ }
338
+ ```
339
+
340
+ If `dns.apiSubdomain` or `dns.zoneName` are missing, defaults to `api.example.com`.
341
+
342
+ ### summarize
343
+
344
+ Aggregates an array of check results into a single pass/fail summary.
345
+
346
+ ```typescript
347
+ function summarize(checks: CheckResult[]): {
348
+ passed: boolean; // true if all checks passed
349
+ total: number; // total number of checks
350
+ failed: number; // number of failed checks
351
+ errors: string[]; // error messages from failed checks
352
+ }
353
+ ```
354
+
355
+ **Example:**
356
+
357
+ ```typescript
358
+ const allChecks = [
359
+ ...verifyAmplifyOutputs('./amplify_outputs.json'),
360
+ ...verifyBuildArtifact('./dist'),
361
+ assertTableCount(count, expected),
362
+ parseEcsStatus(ecsOutput),
363
+ ];
364
+
365
+ const result = summarize(allChecks);
366
+ if (result.passed) {
367
+ console.log(`✅ All ${result.total} checks passed`);
368
+ } else {
369
+ console.error(`❌ ${result.failed}/${result.total} checks failed:`);
370
+ result.errors.forEach(e => console.error(` - ${e}`));
371
+ process.exit(1);
372
+ }
373
+ ```
374
+
375
+ ## Pipeline Engine
376
+
377
+ The pipeline engine runs a sequence of gates, where each gate contains one or more checks. Gates execute in order. When a gate fails, the engine takes the configured action (abort, rollback, or alert) and stops.
378
+
379
+ ### Concepts
380
+
381
+ **Gate** — a named group of checks with a failure action. Example: "Post-Deploy Inventory" gate runs `table-count`, `ecs-status`, and `cognito-groups` checks. If any fail, the action is `rollback`.
382
+
383
+ **Check executor** — an async function that runs a single named check and returns a `CheckResult`. You register these with the engine.
384
+
385
+ **Execution context** — environment information passed to every check executor (environment name, region, project root, a shell exec helper, etc.).
386
+
387
+ **State persistence** — the engine saves its state to disk after each gate. If a run is interrupted, it resumes from the last passed gate.
388
+
389
+ ### Building Gates
390
+
391
+ Use `buildGates()` to generate a gate sequence from an `EnvironmentConfig`:
392
+
393
+ ```typescript
394
+ import { buildGates, DEFAULT_ENVIRONMENTS } from 'anton-bakker-deploy-engine';
395
+
396
+ const gates = buildGates(DEFAULT_ENVIRONMENTS.staging);
397
+ ```
398
+
399
+ This produces 10 gates in order:
400
+
401
+ | # | Gate | Checks | On Fail |
402
+ |---|------|--------|---------|
403
+ | 1 | Preflight | `aws-creds`, `deploy-lock` | abort |
404
+ | 2 | Code Quality | From `preDeployChecks` config | abort |
405
+ | 3 | Infrastructure Validation | `cdk-synth`, `derive-expectations` | abort |
406
+ | 4 | Pre-Deploy Safety | `backup-tables` (if enabled), `prepare-migrations` (if enabled) | abort |
407
+ | 5 | Build | `docker-build`, `frontend-build`, `post-build-verify` | abort |
408
+ | 6 | Deploy | `cdk-deploy`, `ecs-update`, `frontend-deploy`, `seed-data` (if enabled) | rollback |
409
+ | 7 | Post-Deploy Inventory | `table-count`, `ecs-status`, `cognito-groups` | rollback |
410
+ | 8 | Post-Deploy Configuration | `pitr-enabled`, `billing-mode`, `https-enforced`, `s3-not-public` | alert |
411
+ | 9 | Post-Deploy Functional | From `postDeployProbes` config | rollback or alert |
412
+ | 10 | Project Assertions | `manifest-assertions`, `e2e-tests` (if enabled) | alert |
413
+
414
+ Gates 4 and 10 are conditional — they're skipped if the relevant config flags are off.
415
+
416
+ You can also build gates manually:
417
+
418
+ ```typescript
419
+ import type { GateDefinition } from 'anton-bakker-deploy-engine';
420
+
421
+ const gates: GateDefinition[] = [
422
+ {
423
+ id: 'preflight',
424
+ name: 'Preflight',
425
+ checks: ['aws-creds'],
426
+ onFail: 'abort',
427
+ },
428
+ {
429
+ id: 'deploy',
430
+ name: 'Deploy',
431
+ checks: ['cdk-deploy'],
432
+ onFail: 'rollback',
433
+ },
434
+ {
435
+ id: 'verify',
436
+ name: 'Post-Deploy Verification',
437
+ checks: ['api-health', 'table-count'],
438
+ onFail: 'rollback',
439
+ },
440
+ ];
441
+ ```
442
+
443
+ **Failure actions:**
444
+ - `abort` — stop the pipeline, do nothing else
445
+ - `rollback` — stop the pipeline, signal that a rollback is needed (the engine sets `result: 'rolled-back'`)
446
+ - `alert` — stop the pipeline, signal that an alert should be sent
447
+
448
+ **Conditional gates:** Add a `condition` function that receives the `EnvironmentConfig`. The gate is skipped if it returns `false`:
449
+
450
+ ```typescript
451
+ {
452
+ id: 'backup',
453
+ name: 'Backup',
454
+ checks: ['backup-tables'],
455
+ onFail: 'abort',
456
+ condition: (env) => env.backupBefore, // Only run if backups are enabled
457
+ }
458
+ ```
459
+
460
+ ### Writing Check Executors
461
+
462
+ A check executor is an async function that receives the `ExecutionContext` and returns a `CheckResult`:
463
+
464
+ ```typescript
465
+ import type { CheckExecutor } from 'anton-bakker-deploy-engine';
466
+
467
+ const typecheckExecutor: CheckExecutor = async (ctx) => {
468
+ const result = ctx.exec('npx tsc --noEmit');
469
+ return {
470
+ name: 'TypeScript type check',
471
+ passed: result !== null,
472
+ error: result === null ? 'tsc --noEmit failed' : undefined,
473
+ };
474
+ };
475
+
476
+ const tableCountExecutor: CheckExecutor = async (ctx) => {
477
+ const output = ctx.exec(`aws dynamodb list-tables --query TableNames --output json`);
478
+ const { count } = parseTableCount(output, ctx.prefix);
479
+ const expected = ctx.expectations?.tableCount as number ?? 0;
480
+ return assertTableCount(count, expected);
481
+ };
482
+
483
+ const apiHealthExecutor: CheckExecutor = async (ctx) => {
484
+ const response = ctx.exec(`curl -sf https://${ctx.prefix}.example.com/health`);
485
+ return assertApiHealth(response);
486
+ };
487
+ ```
488
+
489
+ Register executors in a `Map<string, CheckExecutor>`:
490
+
491
+ ```typescript
492
+ const checks = new Map<string, CheckExecutor>();
493
+ checks.set('typecheck', typecheckExecutor);
494
+ checks.set('table-count', tableCountExecutor);
495
+ checks.set('api-health', apiHealthExecutor);
496
+ ```
497
+
498
+ The check name in the map must match the check name referenced in gate definitions.
499
+
500
+ If a check executor throws an exception, the engine catches it and records a failed `CheckResult` with the error message.
501
+
502
+ If a gate references a check name that has no registered executor, the engine records a failed result with `"No executor registered for 'name'"`.
503
+
504
+ ### Creating the Execution Context
505
+
506
+ The `ExecutionContext` provides environment information to all check executors:
507
+
508
+ ```typescript
509
+ import type { ExecutionContext } from 'anton-bakker-deploy-engine';
510
+ import { execSync } from 'child_process';
511
+
512
+ const ctx: ExecutionContext = {
513
+ environment: 'staging', // Full environment name
514
+ envShort: 'stg', // Short code for prefixes
515
+ prefix: 's20-stg', // Resource name prefix
516
+ region: 'eu-central-1', // AWS region
517
+ account: '123456789012', // AWS account ID
518
+ projectRoot: process.cwd(), // Project root directory
519
+ dryRun: false, // If true, no state is persisted
520
+ envConfig: DEFAULT_ENVIRONMENTS.staging,
521
+ expectations: { // Optional — from deriveExpectations()
522
+ tableCount: 124,
523
+ cognitoGroups: ['administrators', 'managers'],
524
+ },
525
+ exec: (cmd) => { // Shell command executor
526
+ try {
527
+ return execSync(cmd, { encoding: 'utf-8', timeout: 60_000 });
528
+ } catch {
529
+ return null;
530
+ }
531
+ },
532
+ log: (msg) => process.stdout.write(msg + '\n'),
533
+ };
534
+ ```
535
+
536
+ **Fields:**
537
+
538
+ | Field | Type | Description |
539
+ |-------|------|-------------|
540
+ | `environment` | `string` | Full environment name (`development`, `staging`, `production`) |
541
+ | `envShort` | `string` | Short code used in resource prefixes (`dev`, `stg`, `prod`) |
542
+ | `prefix` | `string` | Resource name prefix (e.g. `s20-dev`) |
543
+ | `region` | `string` | AWS region |
544
+ | `account` | `string` | AWS account ID |
545
+ | `projectRoot` | `string` | Absolute path to project root |
546
+ | `dryRun` | `boolean` | When `true`, no state files are written to disk |
547
+ | `envConfig` | `EnvironmentConfig` | The environment configuration driving this run |
548
+ | `expectations` | `Record<string, unknown>` | Optional key-value store for derived expectations |
549
+ | `exec` | `(cmd: string) => string \| null` | Runs a shell command, returns stdout or `null` on failure |
550
+ | `log` | `(msg: string) => void` | Logging function |
551
+
552
+ ### Running the Pipeline
553
+
554
+ ```typescript
555
+ import { PipelineEngine, buildGates, DEFAULT_ENVIRONMENTS } from 'anton-bakker-deploy-engine';
556
+
557
+ const envConfig = DEFAULT_ENVIRONMENTS.staging;
69
558
  const gates = buildGates(envConfig);
70
559
 
71
- // Run the pipeline
72
- const engine = new PipelineEngine({ gates, checks, ctx: myContext });
560
+ const engine = new PipelineEngine({
561
+ gates,
562
+ checks, // Map<string, CheckExecutor>
563
+ ctx, // ExecutionContext
564
+ stateDir: './orchestrator', // Optional — defaults to <projectRoot>/orchestrator
565
+ });
566
+
73
567
  const report = await engine.run();
568
+ ```
74
569
 
75
- if (report.state.result === 'passed') {
76
- console.log(`✅ All ${report.summary.totalGates} gates passed`);
77
- } else {
78
- console.log(`❌ Failed at gate: ${report.summary.failedGate}`);
570
+ **Constructor options:**
571
+
572
+ | Option | Type | Required | Default | Description |
573
+ |--------|------|----------|---------|-------------|
574
+ | `gates` | `GateDefinition[]` | Yes | — | Ordered list of gates to execute |
575
+ | `checks` | `Map<string, CheckExecutor>` | Yes | — | Registered check executors |
576
+ | `ctx` | `ExecutionContext` | Yes | — | Execution context |
577
+ | `stateDir` | `string` | No | `<projectRoot>/orchestrator` | Directory for state and report files |
578
+
579
+ **The `run()` method returns a `PipelineReport`:**
580
+
581
+ ```typescript
582
+ interface PipelineReport {
583
+ state: PipelineState;
584
+ summary: {
585
+ totalGates: number;
586
+ passed: number;
587
+ failed: number;
588
+ skipped: number;
589
+ duration: number; // Total milliseconds
590
+ failedGate?: string; // ID of the gate that failed
591
+ action?: 'abort' | 'rollback' | 'alert'; // Failure action
592
+ };
79
593
  }
80
594
  ```
81
595
 
82
- ## API Reference
596
+ **Handling the result:**
83
597
 
84
- ### Verification Functions
598
+ ```typescript
599
+ const report = await engine.run();
85
600
 
86
- | Function | Input | Returns | Layer |
87
- |----------|-------|---------|-------|
88
- | `verifyAmplifyOutputs(path)` | Path to amplify_outputs.json | `CheckResult[]` | Exists + Configured |
89
- | `verifyBuildArtifact(distDir)` | Path to dist directory | `CheckResult[]` | Configured |
90
- | `parseTableCount(output, prefix)` | AWS CLI JSON, table prefix | `CheckResult & { count }` | Functional |
91
- | `assertTableCount(actual, expected)` | Counts | `CheckResult` | Functional |
92
- | `parseEcsStatus(output)` | AWS CLI JSON | `CheckResult` | Functional |
93
- | `assertCognitoGroups(actual, required)` | Group arrays | `CheckResult` | Functional |
94
- | `assertApiHealth(response)` | API response string | `CheckResult` | Functional |
95
- | `deriveExpectations(opts)` | Schema/config paths | `DeployExpectations` | Project-specific |
96
- | `summarize(checks)` | `CheckResult[]` | `{ passed, total, failed, errors }` | — |
601
+ switch (report.state.result) {
602
+ case 'passed':
603
+ console.log(`✅ All ${report.summary.totalGates} gates passed in ${report.summary.duration}ms`);
604
+ break;
605
+ case 'failed':
606
+ console.error(`❌ Failed at gate: ${report.summary.failedGate} → ${report.summary.action}`);
607
+ break;
608
+ case 'rolled-back':
609
+ console.error(`🔄 Rolled back at gate: ${report.summary.failedGate}`);
610
+ // Trigger actual rollback logic here
611
+ break;
612
+ }
613
+ ```
97
614
 
98
- ### Pipeline Engine
615
+ ### State Persistence and Resume
99
616
 
100
- - **`PipelineEngine`** — Executes gates in order with state persistence and resume
101
- - **`buildGates(envConfig)`** — Builds gate sequence from environment config
102
- - **`DEFAULT_ENVIRONMENTS`** — Pre-built configs for development, staging, production
103
- - **`PipelineEngine.clearState(dir, env)`** — Archive state for a fresh run
617
+ The engine saves its state to `<stateDir>/deploy-state-<environment>.json` after each gate completes. If a run is interrupted (process crash, network failure, timeout), the next run automatically resumes from the last passed gate.
104
618
 
105
- ### Types
619
+ **State file location:** `orchestrator/deploy-state-staging.json`
106
620
 
107
- `CheckResult`, `GateDefinition`, `GateResult`, `EnvironmentConfig`, `PipelineState`, `PipelineReport`, `CheckExecutor`, `ExecutionContext`
621
+ **Resume behaviour:**
622
+ - Gates that already passed are skipped with a log message
623
+ - The failed gate is re-executed
624
+ - New gates run normally
108
625
 
109
- ## Design Principles
626
+ **Reports:** On successful completion, the engine also saves a report to `<stateDir>/deploy-reports/<timestamp>-<envShort>.json`.
110
627
 
111
- - **Project-agnostic**: no assumptions about consuming project layout
112
- - **Zero dependencies**: only Node.js built-ins (fs, path)
113
- - **Composable**: each function is independently usable
114
- - **Config-driven**: pipeline behaviour determined by `EnvironmentConfig`, not hardcoded logic
628
+ **Dry run mode:** When `ctx.dryRun` is `true`, no state or report files are written. Useful for testing.
115
629
 
116
- ## Development
630
+ ### Clearing State
117
631
 
118
- ```bash
119
- npm test # Run tests (vitest)
120
- npm run build # Compile TypeScript
121
- npm run lint # ESLint with zero warnings
632
+ To force a fresh run (discard previous state), archive the state file:
633
+
634
+ ```typescript
635
+ PipelineEngine.clearState('./orchestrator', 'staging');
636
+ ```
637
+
638
+ This moves `deploy-state-staging.json` to `orchestrator/archive/state-<timestamp>.json`. The next `engine.run()` starts from gate 1.
639
+
640
+ ### Default Environments
641
+
642
+ `DEFAULT_ENVIRONMENTS` provides pre-built configurations for three environments:
643
+
644
+ **development:**
645
+
646
+ ```typescript
647
+ {
648
+ approval: 'never',
649
+ preDeployChecks: ['typecheck', 'lint', 'server-tests', 'schema-parse', 'merge-markers'],
650
+ migrations: true,
651
+ seed: { enabled: true, skipCalendar: true },
652
+ e2eTests: false,
653
+ backupBefore: false,
654
+ cdkNagLevel: 'warning',
655
+ postDeployProbes: ['api-health', 'table-count', 'ecs-status'],
656
+ rollbackOnProbeFailure: false,
657
+ }
658
+ ```
659
+
660
+ **staging:**
661
+
662
+ ```typescript
663
+ {
664
+ approval: 'never',
665
+ preDeployChecks: ['typecheck', 'lint', 'server-tests', 'web-tests', 'schema-parse', 'merge-markers'],
666
+ migrations: true,
667
+ seed: { enabled: true, skipCalendar: false },
668
+ e2eTests: true,
669
+ e2eTestSuite: 'web/e2e/migration/',
670
+ backupBefore: true,
671
+ cdkNagLevel: 'error',
672
+ postDeployProbes: ['api-health', 'table-count', 'ecs-status', 'cognito-groups', 'crud-probe'],
673
+ rollbackOnProbeFailure: true,
674
+ }
122
675
  ```
123
676
 
677
+ **production:**
678
+
679
+ ```typescript
680
+ {
681
+ approval: 'broadening',
682
+ preDeployChecks: ['typecheck', 'lint', 'server-tests', 'web-tests', 'schema-parse',
683
+ 'merge-markers', 'schema-backward-compat'],
684
+ migrations: true,
685
+ seed: { enabled: false },
686
+ e2eTests: true,
687
+ e2eTestSuite: 'web/e2e/smoke/',
688
+ backupBefore: true,
689
+ cdkNagLevel: 'error',
690
+ postDeployProbes: ['api-health', 'table-count', 'ecs-status', 'cognito-groups',
691
+ 'crud-probe', 'auth-probe', 's3-probe'],
692
+ rollbackOnProbeFailure: true,
693
+ }
694
+ ```
695
+
696
+ **EnvironmentConfig fields:**
697
+
698
+ | Field | Type | Description |
699
+ |-------|------|-------------|
700
+ | `approval` | `'never' \| 'broadening' \| 'always'` | When to require human approval |
701
+ | `preDeployChecks` | `string[]` | Check names to run in the Code Quality gate |
702
+ | `migrations` | `boolean` | Whether to run database migrations |
703
+ | `seed` | `{ enabled, skipCalendar? }` | Whether to seed data after deploy |
704
+ | `e2eTests` | `boolean` | Whether to run E2E tests |
705
+ | `e2eTestSuite` | `string` | Path to E2E test suite |
706
+ | `backupBefore` | `boolean` | Whether to backup DynamoDB tables before deploy |
707
+ | `cdkNagLevel` | `'warning' \| 'error'` | CDK Nag severity level |
708
+ | `postDeployProbes` | `string[]` | Check names to run in the Functional gate |
709
+ | `rollbackOnProbeFailure` | `boolean` | Whether functional probe failures trigger rollback |
710
+
711
+ You can extend or override defaults:
712
+
713
+ ```typescript
714
+ const myConfig: EnvironmentConfig = {
715
+ ...DEFAULT_ENVIRONMENTS.staging,
716
+ backupBefore: false, // Skip backups for faster deploys
717
+ postDeployProbes: [...DEFAULT_ENVIRONMENTS.staging.postDeployProbes, 'custom-probe'],
718
+ };
719
+ ```
720
+
721
+ ## Types Reference
722
+
723
+ All types are exported from the package root.
724
+
725
+ | Type | Description |
726
+ |------|-------------|
727
+ | `CheckResult` | Single pass/fail check with name, expected, actual, error, duration |
728
+ | `GateDefinition` | Gate configuration: id, name, check names, failure action, optional condition |
729
+ | `GateResult` | Gate execution result: status, checks, timing |
730
+ | `EnvironmentConfig` | Environment-specific deployment configuration |
731
+ | `PipelineState` | Full pipeline run state: runId, environment, commit, gates, result |
732
+ | `PipelineReport` | Pipeline state + summary statistics |
733
+ | `CheckExecutor` | `(ctx: ExecutionContext) => Promise<CheckResult>` — async check function |
734
+ | `ExecutionContext` | Runtime context passed to every check executor |
735
+ | `DeployExpectations` | Output of `deriveExpectations()`: table count/names, Cognito groups, API domain |
736
+
124
737
  ## Publishing
125
738
 
739
+ The package is published to npm via `scripts/publish.sh`.
740
+
126
741
  ```bash
127
- npm run release:publish # Publish with auto-bump
128
- npm run release:publish:latest # Publish to latest tag
742
+ # Publish (auto-detects tag, auto-bumps patch if version exists)
743
+ npm run release:publish
744
+
745
+ # Publish explicitly to latest tag
746
+ npm run release:publish:latest
747
+
748
+ # Publish to beta tag
749
+ NPM_TAG=beta npm run release:publish
129
750
  ```
130
751
 
131
- Uses npm token from AWS Secrets Manager (`npm/automation-token/anton-bakker-deploy-engine`).
752
+ **What the publish script does:**
753
+
754
+ 1. Commits and pushes any uncommitted changes
755
+ 2. Runs tests (`npm test`)
756
+ 3. Builds (`npm run build`)
757
+ 4. Fetches the npm automation token from AWS Secrets Manager (`npm/automation-token/anton-bakker-deploy-engine`)
758
+ 5. If the current version is already published, auto-bumps patch, commits, and pushes
759
+ 6. Publishes to npm with a temporary `.npmrc` (token never stored on disk)
760
+
761
+ **Environment variables:**
762
+
763
+ | Variable | Default | Description |
764
+ |----------|---------|-------------|
765
+ | `AWS_PROFILE` | `BeyondAmbition` | AWS profile for Secrets Manager access |
766
+ | `AWS_REGION` | `eu-west-1` | AWS region for Secrets Manager |
767
+ | `NPM_SECRET_NAME` | `npm/automation-token/anton-bakker-deploy-engine` | Secrets Manager secret ID |
768
+ | `NPM_TAG` | `latest` | npm publish tag |
769
+ | `REGISTRY` | `https://registry.npmjs.org/` | npm registry URL |
770
+
771
+ ## Development
772
+
773
+ ```bash
774
+ # Install dependencies
775
+ npm install
776
+
777
+ # Run tests
778
+ npm test
779
+
780
+ # Type check (no output)
781
+ npx tsc --noEmit
782
+
783
+ # Build (compiles to dist/)
784
+ npm run build
785
+
786
+ # Lint (zero warnings tolerance)
787
+ npm run lint
788
+ ```
789
+
790
+ **Project structure:**
791
+
792
+ ```
793
+ ├── src/
794
+ │ ├── types.ts # Type definitions
795
+ │ ├── verify.ts # Verification functions
796
+ │ ├── engine.ts # PipelineEngine class
797
+ │ ├── pipeline.ts # Gate builder + default configs
798
+ │ └── index.ts # Public exports
799
+ ├── __tests__/
800
+ │ ├── verify.test.ts # 48 tests for verification functions
801
+ │ ├── engine.test.ts # 13 tests for engine + pipeline
802
+ │ └── fixtures/ # Test fixtures (schema, config, auth stack)
803
+ ├── scripts/
804
+ │ └── publish.sh # npm publish script
805
+ ├── eslint.config.js # ESLint flat config
806
+ ├── tsconfig.json # TypeScript config (strict, ESM, NodeNext)
807
+ └── package.json
808
+ ```
809
+
810
+ **Test runner:** Vitest. All 61 tests run in ~200ms.
811
+
812
+ **TypeScript config:** Strict mode, ESM (`"type": "module"`), `NodeNext` module resolution, source maps and declaration maps enabled.
813
+
814
+ **ESLint rules:** `no-console: error`, `@typescript-eslint/no-unused-vars: error`.
132
815
 
133
816
  ## Licence
134
817