@steve31415/baselib 2.2.2 → 2.4.0

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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // `pw-deploy` — the fleet-wide routine deploy (design:
3
+ // ~/migration/research/deploy-procedure-design.md). All logic lives in
4
+ // ../deploy/deploy.ts; configuration is the app's deploy.config.json.
5
+ //
6
+ // process.exit (not exitCode) so an in-flight Cloud Build child cannot keep
7
+ // a failed deploy's process alive; the server-side build finishes on its own
8
+ // and is reused by the next attempt.
9
+ import { realRunner } from '../deploy/exec.js';
10
+ import { runDeploy } from '../deploy/deploy.js';
11
+ process.exit(await runDeploy({
12
+ repoRoot: process.cwd(),
13
+ runner: realRunner,
14
+ argv: process.argv.slice(2),
15
+ }));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // `pw-rollback` — one-command traffic move to a previously released build.
3
+ // All logic lives in ../deploy/rollback.ts.
4
+ import { realRunner } from '../deploy/exec.js';
5
+ import { runRollback } from '../deploy/rollback.js';
6
+ process.exit(await runRollback({
7
+ repoRoot: process.cwd(),
8
+ runner: realRunner,
9
+ argv: process.argv.slice(2),
10
+ }));
package/dist/db.d.ts CHANGED
@@ -1,10 +1,21 @@
1
1
  import pg from 'pg';
2
- import type { Logger } from './log-core.js';
2
+ import { type Logger } from './log-core.js';
3
3
  export interface PoolOptions {
4
4
  /** Override the database name (default: env DB_NAME / DATABASE_URL path). */
5
5
  database?: string;
6
6
  max?: number;
7
+ /** Receives idle-client errors (see logPoolErrors). Without one they go to
8
+ * stderr — on the Cloud Logging backstop, but not in Axiom. */
9
+ logger?: Logger;
7
10
  }
11
+ /** pg-pool re-emits an idle client's socket error as its own 'error' event —
12
+ * in production "Connection terminated unexpectedly" when Cloud SQL closes an
13
+ * idle connection. Unhandled, that event throws and takes the process down
14
+ * (todo 2026-08-20, notes 2026-08-27 ×2). The failed client has already been
15
+ * removed from the pool and the next checkout opens a fresh connection, so
16
+ * the right response is to log it and carry on. createPool attaches this;
17
+ * exported for apps that build their own pg.Pool. */
18
+ export declare function logPoolErrors(pool: pg.Pool, logger?: Logger): void;
8
19
  export declare function createPool(opts?: PoolOptions): Promise<pg.Pool>;
9
20
  /** End a pool and resolve only once every client's connection has actually
10
21
  * closed. pg-pool's own `end()` resolves as soon as it has *asked* its idle
package/dist/db.js CHANGED
@@ -6,6 +6,7 @@ import { readdir, readFile } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
7
  import pg from 'pg';
8
8
  import { requireEnv } from './config.js';
9
+ import { serializeError } from './log-core.js';
9
10
  // DATE columns come back as 'YYYY-MM-DD' strings, not JS Dates: calendar
10
11
  // dates are timezone-free by meaning, and a Date object smears them across
11
12
  // zones (and JSON-serializes to a full ISO timestamp). Set here — on the
@@ -13,7 +14,27 @@ import { requireEnv } from './config.js';
13
14
  // own pg copy miss this one (file:-dependency installs resolve separate pg
14
15
  // instances).
15
16
  pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v);
17
+ /** pg-pool re-emits an idle client's socket error as its own 'error' event —
18
+ * in production "Connection terminated unexpectedly" when Cloud SQL closes an
19
+ * idle connection. Unhandled, that event throws and takes the process down
20
+ * (todo 2026-08-20, notes 2026-08-27 ×2). The failed client has already been
21
+ * removed from the pool and the next checkout opens a fresh connection, so
22
+ * the right response is to log it and carry on. createPool attaches this;
23
+ * exported for apps that build their own pg.Pool. */
24
+ export function logPoolErrors(pool, logger) {
25
+ pool.on('error', (err) => {
26
+ if (logger)
27
+ logger.error('database pool: idle client error', { error: err });
28
+ else
29
+ console.error('database pool: idle client error', serializeError(err));
30
+ });
31
+ }
16
32
  export async function createPool(opts = {}) {
33
+ const pool = await newPool(opts);
34
+ logPoolErrors(pool, opts.logger);
35
+ return pool;
36
+ }
37
+ async function newPool(opts) {
17
38
  const instance = process.env.CLOUD_SQL_INSTANCE;
18
39
  if (instance) {
19
40
  const { Connector, AuthTypes, IpAddressTypes } = await import('@google-cloud/cloud-sql-connector');
@@ -0,0 +1,5 @@
1
+ import type { DeployConfig, DeployHooks } from './types.js';
2
+ export declare const DEFAULT_ACCOUNT = "coding-agent@plasticine-prod.iam.gserviceaccount.com";
3
+ export declare function validateConfig(raw: unknown): DeployConfig;
4
+ export declare function loadConfig(repoRoot: string): Promise<DeployConfig>;
5
+ export declare function loadHooks(repoRoot: string, config: DeployConfig): Promise<DeployHooks>;
@@ -0,0 +1,67 @@
1
+ // Loading and validation of an app's checked-in deploy.config.json and its
2
+ // optional hooks module.
3
+ import { readFile } from 'node:fs/promises';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { resolve } from 'node:path';
6
+ export const DEFAULT_ACCOUNT = 'coding-agent@plasticine-prod.iam.gserviceaccount.com';
7
+ const NAME = /^[a-z][a-z0-9-]{1,30}$/;
8
+ const PREFIX = /^[a-z0-9][a-z0-9/_-]*\/$/;
9
+ function fail(detail) {
10
+ throw new Error(`deploy.config.json invalid: ${detail}`);
11
+ }
12
+ function validateService(raw, index) {
13
+ const s = raw;
14
+ if (!s.name || !NAME.test(s.name))
15
+ fail(`services[${index}].name`);
16
+ const kinds = ['public-canonical', 'oidc-run-url', 'oidc-audience', 'none'];
17
+ if (!s.health || !kinds.includes(s.health.kind))
18
+ fail(`services[${index}].health.kind`);
19
+ if (s.health.kind === 'oidc-audience' && !s.health.audience)
20
+ fail(`services[${index}].health.audience`);
21
+ if (s.health.kind === 'public-canonical' && !s.canonicalUrl)
22
+ fail(`services[${index}].canonicalUrl`);
23
+ return s;
24
+ }
25
+ export function validateConfig(raw) {
26
+ const c = raw;
27
+ if (!c.app || !NAME.test(c.app))
28
+ fail('app');
29
+ if (!c.project)
30
+ fail('project');
31
+ if (!c.region)
32
+ fail('region');
33
+ if (!c.bucket)
34
+ fail('bucket');
35
+ if (!c.image?.arRepo || !c.image?.name || !c.image?.buildServiceAccount)
36
+ fail('image');
37
+ if (!c.build?.command || !Array.isArray(c.build.contextFiles) || c.build.contextFiles.length === 0)
38
+ fail('build');
39
+ if (!Array.isArray(c.gates))
40
+ fail('gates');
41
+ if (!c.releasePrefix || !PREFIX.test(c.releasePrefix))
42
+ fail('releasePrefix (must end with /)');
43
+ if (c.assets && (!c.assets.dir || !PREFIX.test(c.assets.prefix ?? '')))
44
+ fail('assets (prefix must end with /)');
45
+ if (!Array.isArray(c.services) || c.services.length === 0)
46
+ fail('services');
47
+ c.services.forEach((s, i) => validateService(s, i));
48
+ if (c.updateSecrets) {
49
+ for (const [key, value] of Object.entries(c.updateSecrets)) {
50
+ if (!/^[A-Z0-9_]+$/.test(key) || !/^[a-z0-9-]+--[a-z0-9-]+:[0-9]+$/.test(value))
51
+ fail(`updateSecrets.${key} (want <secret-name>:<numeric version>)`);
52
+ }
53
+ }
54
+ return c;
55
+ }
56
+ export async function loadConfig(repoRoot) {
57
+ const path = resolve(repoRoot, 'deploy.config.json');
58
+ const raw = await readFile(path, 'utf8');
59
+ return validateConfig(JSON.parse(raw));
60
+ }
61
+ export async function loadHooks(repoRoot, config) {
62
+ if (!config.hooks)
63
+ return {};
64
+ const url = pathToFileURL(resolve(repoRoot, config.hooks)).href;
65
+ const mod = (await import(url));
66
+ return mod;
67
+ }
@@ -0,0 +1,13 @@
1
+ import type { Runner } from './types.js';
2
+ export interface DeployOptions {
3
+ repoRoot: string;
4
+ runner: Runner;
5
+ log?: (message: string) => void;
6
+ argv?: string[];
7
+ /** Test seam: skip real sleeps in retry loops. */
8
+ sleep?: (ms: number) => Promise<void>;
9
+ fetchFn?: typeof fetch;
10
+ /** Test seam: the publish workflow's verify gate runs on Node 24. */
11
+ nodeVersion?: string;
12
+ }
13
+ export declare function runDeploy(options: DeployOptions): Promise<number>;
@@ -0,0 +1,405 @@
1
+ // The pw-deploy orchestrator. Flow (design 2026-08-31):
2
+ // preflight -> lock -> build -> assets -> seal -> [image build ‖ gates] ->
3
+ // fence -> per-service update + explicit traffic pin -> hooks/smoke/log check
4
+ // -> release record -> unlock. A failure before the traffic switch leaves the
5
+ // old revision serving; after it, the failure is reported with the rollback
6
+ // command printed.
7
+ import { createHash } from 'node:crypto';
8
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
9
+ import { hostname } from 'node:os';
10
+ import { join, resolve } from 'node:path';
11
+ import { DEFAULT_ACCOUNT, loadConfig, loadHooks } from './config.js';
12
+ import { must, mustJson } from './exec.js';
13
+ import { openEvidence, PhaseTimer } from './evidence.js';
14
+ import { acquireLock, clearHold, publishRetainedAssets, readHold, readReleaseRecord, releaseLock, verifyLock, writeReleaseRecord, } from './gcs.js';
15
+ import { consistentBaseSha, servingStateOf, SHA40 } from './plan.js';
16
+ const RELEASE_DEADLINE_MS = 25 * 60_000;
17
+ const IMAGE_DIGEST = /@sha256:[0-9a-f]{64}$/;
18
+ export async function runDeploy(options) {
19
+ const log = options.log ?? ((m) => console.error(`[pw-deploy] ${m}`));
20
+ const runner = options.runner;
21
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
22
+ const fetchFn = options.fetchFn ?? fetch;
23
+ const overrideHold = (options.argv ?? []).includes('--override-hold');
24
+ const timer = new PhaseTimer(log);
25
+ const startedAt = Date.now();
26
+ let trafficMoved = false;
27
+ let evidenceDir = '';
28
+ let lock = null;
29
+ try {
30
+ // ---- preflight ----
31
+ const config = await loadConfig(options.repoRoot);
32
+ const hooks = await loadHooks(options.repoRoot, config);
33
+ const gcloudBase = ['--project', config.project];
34
+ await must(runner, 'git', ['fetch', 'origin', 'main'], { cwd: options.repoRoot });
35
+ const branch = (await must(runner, 'git', ['branch', '--show-current'], { cwd: options.repoRoot })).stdout.trim();
36
+ if (branch !== 'main')
37
+ throw new Error('deploys run from main');
38
+ const dirty = (await must(runner, 'git', ['status', '--porcelain=v1', '--untracked-files=all'], {
39
+ cwd: options.repoRoot,
40
+ })).stdout.trim();
41
+ if (dirty)
42
+ throw new Error('deploys require a clean worktree');
43
+ const sha = (await must(runner, 'git', ['rev-parse', 'HEAD'], { cwd: options.repoRoot })).stdout.trim();
44
+ const originMain = (await must(runner, 'git', ['rev-parse', 'origin/main'], { cwd: options.repoRoot })).stdout.trim();
45
+ if (sha !== originMain)
46
+ throw new Error('deploys require pushed main');
47
+ if (!SHA40.test(sha))
48
+ throw new Error(`unexpected HEAD sha: ${sha}`);
49
+ const account = (await must(runner, 'gcloud', ['config', 'get', 'account'], {})).stdout.trim();
50
+ if (account !== (config.account ?? DEFAULT_ACCOUNT)) {
51
+ throw new Error(`gcloud account must be ${config.account ?? DEFAULT_ACCOUNT} (is ${account})`);
52
+ }
53
+ const project = (await must(runner, 'gcloud', ['config', 'get', 'project'], {})).stdout.trim();
54
+ if (project !== config.project) {
55
+ throw new Error(`gcloud project must be ${config.project} (is ${project})`);
56
+ }
57
+ const nodeVersion = options.nodeVersion ?? process.version;
58
+ if (!nodeVersion.startsWith('v22')) {
59
+ throw new Error(`Node 22 required (running ${nodeVersion})`);
60
+ }
61
+ const evidence = await openEvidence(config.app, sha);
62
+ evidenceDir = evidence.dir;
63
+ log(`deploying ${config.app} @ ${sha.slice(0, 12)}`);
64
+ // node_modules freshness: hash the lockfile against our stamp.
65
+ const lockHash = createHash('sha256')
66
+ .update(await readFile(join(options.repoRoot, 'package-lock.json')))
67
+ .digest('hex');
68
+ const stampPath = join(options.repoRoot, '.deploy', 'npm-ci.stamp');
69
+ let stamp = '';
70
+ try {
71
+ stamp = (await readFile(stampPath, 'utf8')).trim();
72
+ }
73
+ catch {
74
+ /* no stamp yet */
75
+ }
76
+ if (stamp !== lockHash) {
77
+ log('node_modules stamp stale or missing — running npm ci');
78
+ await must(runner, 'npm', ['ci'], { cwd: options.repoRoot, timeoutMs: 10 * 60_000 });
79
+ await mkdir(join(options.repoRoot, '.deploy'), { recursive: true });
80
+ await writeFile(stampPath, `${lockHash}\n`);
81
+ }
82
+ const holdUri = `gs://${config.bucket}/${config.app}/deploy.hold`;
83
+ const hold = await readHold(runner, holdUri);
84
+ if (hold && hold.heldShas.includes(sha) && !overrideHold) {
85
+ throw new Error(`SHA ${sha.slice(0, 12)} is held (${hold.reason} at ${hold.at}); ` +
86
+ 'a rollback moved traffic off it. Deploy a fix, or pass --override-hold.');
87
+ }
88
+ const serving = [];
89
+ for (const service of config.services) {
90
+ const described = await mustJson(runner, 'gcloud', [
91
+ 'run', 'services', 'describe', service.name,
92
+ ...gcloudBase, '--region', config.region, '--format=json',
93
+ ]);
94
+ await evidence.save(`${service.name}-before.json`, JSON.stringify(described, null, 2));
95
+ serving.push(servingStateOf(service.name, described));
96
+ }
97
+ const base = consistentBaseSha(serving, sha);
98
+ if (!base.ok)
99
+ throw new Error(base.detail);
100
+ const baseSha = base.baseSha;
101
+ if (config.refuseMigrationDiffOnDeploy && baseSha && baseSha !== sha) {
102
+ await must(runner, 'git', ['cat-file', '-e', `${baseSha}^{commit}`], {
103
+ cwd: options.repoRoot,
104
+ });
105
+ const diff = (await must(runner, 'git', ['diff', '--name-only', '--diff-filter=ACMRD', `${baseSha}..${sha}`, '--', 'migrations'], { cwd: options.repoRoot })).stdout.trim();
106
+ if (diff) {
107
+ throw new Error(`migrations changed since serving build ${baseSha.slice(0, 12)}; ` +
108
+ 'run the migration operator workflow first');
109
+ }
110
+ }
111
+ const priorRecord = await readReleaseRecord(runner, config.bucket, config.releasePrefix, sha);
112
+ const skipGates = priorRecord !== null;
113
+ if (skipGates)
114
+ log('re-release of an already-released SHA — gates skipped');
115
+ // ---- lock ----
116
+ timer.enter('lock');
117
+ const lockUri = `gs://${config.bucket}/${config.app}/deploy.lock`;
118
+ lock = await acquireLock(runner, lockUri, `host=${hostname()}\nsha=${sha}\nstarted=${new Date().toISOString()}\n`, { log, sleep });
119
+ // ---- build ----
120
+ timer.enter('build');
121
+ const build = await must(runner, 'bash', ['-c', config.build.command], {
122
+ cwd: options.repoRoot,
123
+ env: { BUILD_ID: sha },
124
+ timeoutMs: 10 * 60_000,
125
+ });
126
+ await evidence.save('build.log', build.stdout + build.stderr);
127
+ const ctx = {
128
+ sha,
129
+ baseSha,
130
+ config,
131
+ repoRoot: options.repoRoot,
132
+ contextDir: '',
133
+ evidenceDir: evidence.dir,
134
+ serving,
135
+ exec: runner,
136
+ log,
137
+ };
138
+ // ---- assets (before sealing: outputs may belong in the image) ----
139
+ timer.enter('assets');
140
+ if (hooks.publishAssets) {
141
+ await hooks.publishAssets(ctx);
142
+ }
143
+ else if (config.assets) {
144
+ const result = await publishRetainedAssets(runner, {
145
+ dir: resolve(options.repoRoot, config.assets.dir),
146
+ bucket: config.bucket,
147
+ prefix: config.assets.prefix,
148
+ buildId: sha,
149
+ });
150
+ log(`retained assets: ${result.published} published, ${result.reused} reused`);
151
+ }
152
+ // ---- seal the build context ----
153
+ timer.enter('seal');
154
+ const contextDir = join(options.repoRoot, '.deploy', `context-${sha.slice(0, 12)}`);
155
+ ctx.contextDir = contextDir;
156
+ await rm(contextDir, { recursive: true, force: true });
157
+ await mkdir(contextDir, { recursive: true });
158
+ for (const name of [
159
+ 'Dockerfile', 'cloudbuild.yaml', 'package.json', 'package-lock.json',
160
+ ...config.build.contextFiles,
161
+ ]) {
162
+ await cp(resolve(options.repoRoot, name), join(contextDir, name), { recursive: true });
163
+ }
164
+ // ---- image (async) ‖ gates ----
165
+ timer.enter('image+gates');
166
+ const imageUri = `${config.region}-docker.pkg.dev/${config.project}/${config.image.arRepo}/${config.image.name}:${sha}`;
167
+ const describeImage = () => mustJson(runner, 'gcloud', [
168
+ 'artifacts', 'docker', 'images', 'describe', imageUri, ...gcloudBase, '--format=json',
169
+ ]);
170
+ const imageStart = Date.now();
171
+ const imagePromise = (async () => {
172
+ const existing = await runner('gcloud', [
173
+ 'artifacts', 'docker', 'images', 'describe', imageUri, ...gcloudBase, '--format=json',
174
+ ]);
175
+ if (existing.code === 0) {
176
+ log('reusing existing immutable commit image');
177
+ }
178
+ else {
179
+ const submit = await must(runner, 'gcloud', [
180
+ 'builds', 'submit', contextDir,
181
+ '--config', join(contextDir, 'cloudbuild.yaml'),
182
+ '--region', config.region, ...gcloudBase,
183
+ '--substitutions', `_IMAGE=${imageUri}`,
184
+ '--suppress-logs', '--format=json',
185
+ ], { timeoutMs: 15 * 60_000 });
186
+ await evidence.save('cloud-build.json', submit.stdout);
187
+ }
188
+ const digest = (await describeImage()).image_summary?.fully_qualified_digest ?? '';
189
+ if (!IMAGE_DIGEST.test(digest))
190
+ throw new Error(`unexpected image digest: ${digest}`);
191
+ return digest;
192
+ })().then((digest) => {
193
+ timer.record('image', Date.now() - imageStart);
194
+ return digest;
195
+ });
196
+ // Surface an image failure even if a gate throws first.
197
+ let imageFailure = null;
198
+ const imageGuarded = imagePromise.catch((error) => {
199
+ imageFailure = error;
200
+ return null;
201
+ });
202
+ if (!skipGates) {
203
+ for (const [index, gate] of config.gates.entries()) {
204
+ const gateStart = Date.now();
205
+ const result = await runner('bash', ['-c', gate], {
206
+ cwd: options.repoRoot,
207
+ timeoutMs: 15 * 60_000,
208
+ });
209
+ await evidence.save(`gate-${index}.log`, result.stdout + result.stderr);
210
+ timer.record(`gate:${gate}`, Date.now() - gateStart);
211
+ if (result.code !== 0) {
212
+ const tail = (result.stderr.trim() || result.stdout.trim()).slice(-1500);
213
+ throw new Error(`gate failed: ${gate}\n${tail}`);
214
+ }
215
+ log(`gate passed: ${gate}`);
216
+ }
217
+ }
218
+ const imageDigest = await imageGuarded;
219
+ if (imageDigest === null)
220
+ throw imageFailure ?? new Error('image build failed');
221
+ ctx.imageDigest = imageDigest;
222
+ // ---- release (the fence, then per-service update + explicit pin) ----
223
+ timer.enter('release');
224
+ if (Date.now() - startedAt > RELEASE_DEADLINE_MS) {
225
+ throw new Error('deploy exceeded its 25-minute deadline; lock may be breakable — aborting before traffic');
226
+ }
227
+ if (!(await verifyLock(runner, lock))) {
228
+ lock = null; // someone broke and superseded us; never touch their lock
229
+ throw new Error('deploy lock lost (superseded by another deploy or rollback); aborting before traffic');
230
+ }
231
+ if (hooks.preRelease)
232
+ await hooks.preRelease(ctx);
233
+ const deployed = [];
234
+ for (const [index, service] of config.services.entries()) {
235
+ const current = serving[index];
236
+ if (current.buildSha === sha && current.imageDigest === imageDigest) {
237
+ log(`${service.name}: already serving ${sha.slice(0, 12)}`);
238
+ deployed.push({
239
+ name: service.name, revision: current.revision, imageDigest, updated: false,
240
+ });
241
+ continue;
242
+ }
243
+ const updateArgs = [
244
+ 'run', 'services', 'update', service.name,
245
+ ...gcloudBase, '--region', config.region, '--quiet', '--format=json',
246
+ '--image', imageDigest,
247
+ '--update-env-vars', `BUILD_ID=${sha}`,
248
+ '--update-labels', `pw-build-sha=${sha.slice(0, 40)}`,
249
+ ];
250
+ if (config.updateSecrets) {
251
+ const pairs = Object.entries(config.updateSecrets)
252
+ .map(([k, v]) => `${k}=${v}`)
253
+ .join(',');
254
+ updateArgs.push('--update-secrets', pairs);
255
+ }
256
+ const updated = await must(runner, 'gcloud', updateArgs, { timeoutMs: 10 * 60_000 });
257
+ await evidence.save(`${service.name}-update.json`, updated.stdout);
258
+ const after = await mustJson(runner, 'gcloud', [
259
+ 'run', 'services', 'describe', service.name,
260
+ ...gcloudBase, '--region', config.region, '--format=json',
261
+ ]);
262
+ const newRevision = after.status?.latestCreatedRevisionName ?? '';
263
+ if (!newRevision || newRevision === current.revision) {
264
+ throw new Error(`${service.name}: expected a new revision, got ${newRevision || 'none'}`);
265
+ }
266
+ if (after.status?.latestReadyRevisionName !== newRevision) {
267
+ throw new Error(`${service.name}: revision ${newRevision} is not ready`);
268
+ }
269
+ await must(runner, 'gcloud', [
270
+ 'run', 'services', 'update-traffic', service.name,
271
+ ...gcloudBase, '--region', config.region, '--quiet', '--format=json',
272
+ '--to-revisions', `${newRevision}=100`,
273
+ ]);
274
+ trafficMoved = true;
275
+ log(`${service.name}: serving ${newRevision}`);
276
+ deployed.push({ name: service.name, revision: newRevision, imageDigest, updated: true });
277
+ }
278
+ // ---- post ----
279
+ timer.enter('post');
280
+ if (hooks.postTraffic)
281
+ await hooks.postTraffic(ctx);
282
+ for (const [index, service] of config.services.entries()) {
283
+ const target = deployed[index];
284
+ if (service.health.kind === 'none')
285
+ continue;
286
+ const path = service.health.path ?? '/health';
287
+ let url;
288
+ let headers = {};
289
+ if (service.health.kind === 'public-canonical') {
290
+ url = `${service.canonicalUrl}${path}`;
291
+ }
292
+ else {
293
+ const described = await mustJson(runner, 'gcloud', [
294
+ 'run', 'services', 'describe', service.name,
295
+ ...gcloudBase, '--region', config.region, '--format=json',
296
+ ]);
297
+ const runUrl = described.status?.url ?? '';
298
+ if (!/^https:\/\/[a-z0-9.-]+$/.test(runUrl)) {
299
+ throw new Error(`${service.name}: unexpected run URL ${runUrl}`);
300
+ }
301
+ const audience = service.health.kind === 'oidc-audience' ? service.health.audience : runUrl;
302
+ const token = (await must(runner, 'gcloud', ['auth', 'print-identity-token', `--audiences=${audience}`])).stdout.trim();
303
+ headers = { authorization: `Bearer ${token}` };
304
+ url = `${runUrl}${path}`;
305
+ }
306
+ let lastDetail = '';
307
+ let passed = false;
308
+ for (let attempt = 0; attempt < 10; attempt += 1) {
309
+ try {
310
+ const response = await fetchFn(url, { headers });
311
+ const body = (await response.json());
312
+ const trace = response.headers.get('x-cloud-trace-context') ?? '';
313
+ if (!response.ok || body.ok !== true) {
314
+ lastDetail = `status ${response.status}`;
315
+ }
316
+ else if (body.revision !== undefined && body.revision !== target.revision) {
317
+ lastDetail = `revision ${body.revision} != ${target.revision}`;
318
+ }
319
+ else {
320
+ target.traceId = trace.split('/')[0] || undefined;
321
+ passed = true;
322
+ break;
323
+ }
324
+ }
325
+ catch (error) {
326
+ lastDetail = String(error);
327
+ }
328
+ await sleep(3000);
329
+ }
330
+ if (!passed)
331
+ throw new Error(`${service.name}: smoke failed at ${url} (${lastDetail})`);
332
+ log(`${service.name}: smoke passed`);
333
+ }
334
+ if (hooks.smoke)
335
+ await hooks.smoke(ctx);
336
+ // Log check with an ingestion barrier: wait for the smoke request's own
337
+ // trace to be queryable, then scan the new revision for ERRORs.
338
+ for (const target of deployed) {
339
+ if (!target.updated)
340
+ continue;
341
+ if (target.traceId && /^[0-9a-f]{16,32}$/.test(target.traceId)) {
342
+ let seen = false;
343
+ for (let attempt = 0; attempt < 10; attempt += 1) {
344
+ const read = await mustJson(runner, 'gcloud', [
345
+ 'logging', 'read',
346
+ `trace="projects/${config.project}/traces/${target.traceId}" AND resource.labels.revision_name="${target.revision}"`,
347
+ ...gcloudBase, '--limit=1', '--format=json',
348
+ ]);
349
+ if (Array.isArray(read) && read.length > 0) {
350
+ seen = true;
351
+ break;
352
+ }
353
+ await sleep(2000);
354
+ }
355
+ if (!seen)
356
+ log(`${target.name}: smoke trace not yet ingested — error scan may be early`);
357
+ }
358
+ const errors = await mustJson(runner, 'gcloud', [
359
+ 'logging', 'read',
360
+ `resource.type=cloud_run_revision AND resource.labels.revision_name="${target.revision}" AND severity>=ERROR`,
361
+ ...gcloudBase, '--limit=5', '--format=json',
362
+ ]);
363
+ if (Array.isArray(errors) && errors.length > 0) {
364
+ await evidence.save(`${target.name}-errors.json`, JSON.stringify(errors, null, 2));
365
+ throw new Error(`${target.name}: ${errors.length} ERROR log entr${errors.length === 1 ? 'y' : 'ies'} on ${target.revision} (see evidence)`);
366
+ }
367
+ }
368
+ if (hooks.postSuccess)
369
+ await hooks.postSuccess(ctx);
370
+ timer.enter('record');
371
+ timer.close();
372
+ const record = await writeReleaseRecord(runner, config.bucket, config.releasePrefix, {
373
+ sha,
374
+ at: new Date().toISOString(),
375
+ app: config.app,
376
+ services: deployed.map(({ name, revision, imageDigest: digest }) => ({
377
+ name, revision, imageDigest: digest,
378
+ })),
379
+ timingsMs: timer.timings,
380
+ });
381
+ if (hold && !hold.heldShas.includes(sha)) {
382
+ await clearHold(runner, holdUri);
383
+ log('cleared rollback hold (a newer build deployed successfully)');
384
+ }
385
+ log(`deployed ${sha.slice(0, 12)} (${record})`);
386
+ log(`timings:\n${timer.table()}`);
387
+ log(`rollback: npm run rollback (or: npm run rollback -- <sha>)`);
388
+ return 0;
389
+ }
390
+ catch (error) {
391
+ const message = error instanceof Error ? error.message : String(error);
392
+ console.error(message);
393
+ if (trafficMoved) {
394
+ console.error('NOTE: traffic already moved to the new build; to undo: npm run rollback');
395
+ }
396
+ console.error(`deploy failed in phase: ${timer.current}`);
397
+ if (evidenceDir)
398
+ console.error(`evidence: ${evidenceDir}`);
399
+ return 1;
400
+ }
401
+ finally {
402
+ if (lock)
403
+ await releaseLock(runner, lock);
404
+ }
405
+ }
@@ -0,0 +1,19 @@
1
+ export interface Evidence {
2
+ dir: string;
3
+ save: (name: string, content: string) => Promise<void>;
4
+ }
5
+ export declare function openEvidence(app: string, sha: string): Promise<Evidence>;
6
+ export declare class PhaseTimer {
7
+ private log;
8
+ readonly timings: Record<string, number>;
9
+ private phaseName;
10
+ private phaseStart;
11
+ constructor(log: (message: string) => void);
12
+ get current(): string;
13
+ /** Close the current phase and open the next. */
14
+ enter(name: string): void;
15
+ close(): void;
16
+ /** Record a concurrently-measured span without switching phases. */
17
+ record(name: string, ms: number): void;
18
+ table(): string;
19
+ }
@@ -0,0 +1,60 @@
1
+ // Evidence directories and phase timing for pw-deploy/pw-rollback. The final
2
+ // stderr lines on failure are the one-screen summary the Coder daemon
3
+ // persists (2000-char tail), so keep them short and put detail in files.
4
+ import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ const KEEP_RUNS = 10;
8
+ export async function openEvidence(app, sha) {
9
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
10
+ const root = join(homedir(), '.deploy-evidence', app);
11
+ const dir = join(root, `${stamp}-${sha.slice(0, 7)}`);
12
+ await mkdir(dir, { recursive: true });
13
+ try {
14
+ const runs = (await readdir(root)).sort();
15
+ for (const old of runs.slice(0, Math.max(0, runs.length - KEEP_RUNS))) {
16
+ await rm(join(root, old), { recursive: true, force: true });
17
+ }
18
+ }
19
+ catch {
20
+ /* housekeeping only */
21
+ }
22
+ return {
23
+ dir,
24
+ save: async (name, content) => {
25
+ await writeFile(join(dir, name), content);
26
+ },
27
+ };
28
+ }
29
+ export class PhaseTimer {
30
+ log;
31
+ timings = {};
32
+ phaseName = 'preflight';
33
+ phaseStart = Date.now();
34
+ constructor(log) {
35
+ this.log = log;
36
+ }
37
+ get current() {
38
+ return this.phaseName;
39
+ }
40
+ /** Close the current phase and open the next. */
41
+ enter(name) {
42
+ this.close();
43
+ this.phaseName = name;
44
+ this.phaseStart = Date.now();
45
+ }
46
+ close() {
47
+ const elapsed = Date.now() - this.phaseStart;
48
+ this.timings[this.phaseName] = (this.timings[this.phaseName] ?? 0) + elapsed;
49
+ this.log(`phase ${this.phaseName}: ${(elapsed / 1000).toFixed(1)}s`);
50
+ }
51
+ /** Record a concurrently-measured span without switching phases. */
52
+ record(name, ms) {
53
+ this.timings[name] = ms;
54
+ }
55
+ table() {
56
+ return Object.entries(this.timings)
57
+ .map(([name, ms]) => ` ${name}: ${(ms / 1000).toFixed(1)}s`)
58
+ .join('\n');
59
+ }
60
+ }