blogwright 0.4.0-beta.4 → 0.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,548 @@
1
+ /**
2
+ * The SSR deploy contract: build-result reading, revision fingerprints, the
3
+ * signed endpoint probes, publishing a revision, the restore path when a
4
+ * revision is Active but never serves, and rollback from a recorded artifact.
5
+ *
6
+ * `commands.ts` orchestrates; this module owns the sequence. Every cloud call
7
+ * goes through `ctx.clients`, so the whole contract is covered by transport
8
+ * mocks - `floci` does not emulate `lambda-web`.
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ import { pollUntil, runtimeEntryPoint, WEB_FUNCTION_RUNTIME, } from 'blogwright-core';
12
+ import { artifactKeys, invalidateChanged, invalidateCloudFront, writeManifest, } from './deploy.js';
13
+ import { formatDuration } from './render.js';
14
+ /** The environment's `ssr` block, or a refusal naming what is missing. */
15
+ export function requireSsr(ctx) {
16
+ const config = ctx.config.ssr;
17
+ if (!config)
18
+ throw new Error(`environment "${ctx.env}" has no config.ssr block`);
19
+ return config;
20
+ }
21
+ /** How long the deploy waits for a revision to leave `Creating`. */
22
+ const REVISION_ACTIVATION_TIMEOUT_MS = 5 * 60_000;
23
+ /** How long the deploy waits for the endpoint to answer below 500. */
24
+ const READINESS_PROBE_TIMEOUT_MS = 120_000;
25
+ const PROBE_INTERVAL_MS = 5_000;
26
+ /** The tag the fingerprint is stored under in a revision's `description`. */
27
+ export const FINGERPRINT_PREFIX = 'blogwright:';
28
+ function webRoleArn(ctx) {
29
+ return String(ctx.state.resources['iam-web-role']?.arn);
30
+ }
31
+ /** The endpoint's domain from bootstrapped state. */
32
+ function endpointDomain(ctx) {
33
+ const domain = ctx.state.resources['web-function']?.endpointDomainName;
34
+ if (typeof domain !== 'string' || domain.length === 0) {
35
+ throw new Error(`no Web Function endpoint recorded for "${ctx.env}" - run bootstrap first`);
36
+ }
37
+ return domain;
38
+ }
39
+ /**
40
+ * The revision-scoped settings as sent: the resolved environment carries
41
+ * `NODE_ENV=production` unless `ssr.env` overrides it, and
42
+ * `AWS_LAMBDA_NODEJS_ENTRYPOINT` whenever the entry point is not the runtime's
43
+ * `index.js` default. With `ssr.shim` set, the entry point is the generated
44
+ * shim, which starts `ssr.entryPoint` itself.
45
+ */
46
+ export function revisionSettings(ctx) {
47
+ const config = requireSsr(ctx);
48
+ const entryPoint = runtimeEntryPoint(config);
49
+ const env = { NODE_ENV: 'production', ...config.env };
50
+ if (entryPoint !== 'index.js')
51
+ env['AWS_LAMBDA_NODEJS_ENTRYPOINT'] = entryPoint;
52
+ return {
53
+ entryPoint,
54
+ env,
55
+ timeoutSeconds: config.timeoutSeconds,
56
+ maxConcurrencyPerEnvironment: config.maxConcurrencyPerEnvironment,
57
+ };
58
+ }
59
+ /**
60
+ * The revision fingerprint: a sha256 over the artifact digest, the runtime,
61
+ * the execution role, the log group and the revision-scoped settings as they
62
+ * will be sent. Two deploys with the same fingerprint produce the same
63
+ * revision, so the second creates nothing.
64
+ */
65
+ export function revisionFingerprint(ctx, artifactSha256) {
66
+ const settings = revisionSettings(ctx);
67
+ const env = Object.fromEntries(Object.entries(settings.env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
68
+ return createHash('sha256')
69
+ .update(JSON.stringify({
70
+ artifactSha256,
71
+ runtime: WEB_FUNCTION_RUNTIME,
72
+ executionRoleArn: webRoleArn(ctx),
73
+ logGroup: ctx.names.webLogGroup,
74
+ entryPoint: settings.entryPoint,
75
+ env,
76
+ timeoutSeconds: settings.timeoutSeconds,
77
+ maxConcurrencyPerEnvironment: settings.maxConcurrencyPerEnvironment,
78
+ }))
79
+ .digest('hex');
80
+ }
81
+ /** The fingerprint a revision was created with, from its `description`. */
82
+ function revisionFingerprintOf(revision) {
83
+ const description = revision.description;
84
+ return description?.startsWith(FINGERPRINT_PREFIX)
85
+ ? description.slice(FINGERPRINT_PREFIX.length)
86
+ : undefined;
87
+ }
88
+ /** A signed GET of the endpoint root - the only evidence that a deploy serves. */
89
+ async function probeWebEndpoint(ctx) {
90
+ return ctx.clients.signing.sendToUrl({
91
+ url: `https://${endpointDomain(ctx)}/`,
92
+ method: 'GET',
93
+ service: 'lambda',
94
+ });
95
+ }
96
+ /**
97
+ * True for the one signal that distinguishes an authorization refusal from an
98
+ * application answer: Lambda omits `lambda-web-error-type` when the caller may
99
+ * not invoke the endpoint at all.
100
+ */
101
+ export function isInvokeAuthorizationFailure(response) {
102
+ return response.statusCode === 403 && response.headers['lambda-web-error-type'] === undefined;
103
+ }
104
+ /**
105
+ * The mandatory preflight, run BEFORE anything is built or published. Any
106
+ * answer from the application passes, including the placeholder's 503; only a
107
+ * caller that cannot invoke the endpoint at all stops here.
108
+ */
109
+ export async function preflightWebFunction(ctx) {
110
+ const response = await probeWebEndpoint(ctx);
111
+ if (!isInvokeAuthorizationFailure(response))
112
+ return;
113
+ throw new Error('this caller may not invoke the Web Function endpoint: it answered HTTP 403 without an ' +
114
+ 'application error, which is how Lambda reports a denied ' +
115
+ `lambda:InvokeWebFunctionEndpoint on ${ctx.names.webFunction}. Grant the action to the ` +
116
+ 'caller (the GitHub OIDC deploy role carries it) and retry; nothing was built or published.');
117
+ }
118
+ /** Read the agent's result sidecar for a hash. */
119
+ export async function readWebBuildResult(ctx, hash) {
120
+ const { resultKey } = artifactKeys(hash);
121
+ const text = await ctx.clients.s3.getObjectText(ctx.names.bucket, resultKey);
122
+ if (!text) {
123
+ throw new Error(`no SSR build result at s3://${ctx.names.bucket}/${resultKey} - the build agent did not ` +
124
+ 'package the output');
125
+ }
126
+ const parsed = JSON.parse(text);
127
+ if (!parsed.artifactKey || !parsed.artifactSha256) {
128
+ throw new Error(`${resultKey} carries no artifact key/digest - rebuild with a current agent`);
129
+ }
130
+ return {
131
+ artifactKey: parsed.artifactKey,
132
+ artifactVersionId: parsed.artifactVersionId ?? 'null',
133
+ artifactSha256: parsed.artifactSha256,
134
+ artifactSize: parsed.artifactSize ?? 0,
135
+ staleStaticKeys: parsed.staleStaticKeys ?? [],
136
+ };
137
+ }
138
+ /**
139
+ * The revision a `LatestRevision` endpoint routes to: the newest Active one in
140
+ * `ListWebFunctionRevisions`, which the service orders newest first. The list
141
+ * yields summaries only, so the revision's configuration is read back with a
142
+ * second call - that is what a failed readiness probe restores from.
143
+ */
144
+ async function servingRevision(ctx) {
145
+ const summaries = await ctx.clients.lambdaWeb.listWebFunctionRevisions(ctx.names.webFunction);
146
+ const newest = summaries.find((revision) => /^Active$/i.test(revision.state));
147
+ if (!newest)
148
+ return undefined;
149
+ return ctx.clients.lambdaWeb.getWebFunctionRevision(ctx.names.webFunction, newest.revisionId);
150
+ }
151
+ /** Poll a revision until it leaves `Pending`, or report the state it settled in. */
152
+ async function awaitRevisionActive(ctx, revision) {
153
+ const settled = await pollUntil(() => ctx.clients.lambdaWeb.getWebFunctionRevision(ctx.names.webFunction, revision.revisionId), (current) => Boolean(current) && !/^Pending$/i.test(current?.state ?? ''), { intervalMs: 5_000, timeoutMs: REVISION_ACTIVATION_TIMEOUT_MS });
154
+ return settled ?? revision;
155
+ }
156
+ /** The revision's errors, as one readable line, each naming its attribute. */
157
+ function revisionErrors(revision) {
158
+ if (revision.errors.length === 0) {
159
+ return `revision state is ${revision.state}${revision.stateReason ? ` (${revision.stateReason})` : ''}`;
160
+ }
161
+ return revision.errors
162
+ .map((error) => [error.attribute, [error.errorCode, error.errorMessage].filter(Boolean).join(': ')]
163
+ .filter(Boolean)
164
+ .join(' '))
165
+ .join('; ');
166
+ }
167
+ async function waitForServing(ctx, pacing = {}) {
168
+ // The endpoint has already cut over when this runs, so a probe that throws (a
169
+ // reset connection, a DNS miss) is one more "not serving yet" answer: letting
170
+ // it escape would skip the restore and leave the outcome unrecorded.
171
+ const serving = (response) => response !== undefined && response.statusCode < 500;
172
+ let warned = false;
173
+ const response = await pollUntil(() => probeWebEndpoint(ctx).catch((err) => {
174
+ if (!warned) {
175
+ ctx.logger.warn(`readiness probe did not reach the endpoint: ${err.message}`);
176
+ warned = true;
177
+ }
178
+ return undefined;
179
+ }), serving, {
180
+ intervalMs: pacing.intervalMs ?? PROBE_INTERVAL_MS,
181
+ timeoutMs: pacing.timeoutMs ?? READINESS_PROBE_TIMEOUT_MS,
182
+ });
183
+ return serving(response);
184
+ }
185
+ /** Remove the site keys the agent left behind, once a revision is verified. */
186
+ async function deleteStaleKeys(ctx, keys) {
187
+ let deleted = 0;
188
+ for (const key of keys) {
189
+ // Only live site objects: the agent computes these under site/ and a
190
+ // malformed result file must not be able to delete anything else.
191
+ if (!key.startsWith('site/'))
192
+ continue;
193
+ await ctx.clients.s3.deleteObject(ctx.names.bucket, key);
194
+ deleted += 1;
195
+ }
196
+ if (deleted > 0)
197
+ ctx.logger.ok(`removed ${deleted} stale site object(s)`);
198
+ return deleted;
199
+ }
200
+ /** Invalidate everything a new revision may have changed. */
201
+ async function invalidateAll(ctx) {
202
+ await invalidateCloudFront(ctx, ['/*']);
203
+ return { mode: 'all', count: 0 };
204
+ }
205
+ /**
206
+ * Where a publish in flight is noted, per fingerprint. The note is written
207
+ * before the revision is created and removed once the cache is invalidated and
208
+ * the manifest recorded. A later deploy that finds the fingerprint already
209
+ * serving reads it to tell a finished publish from one that stopped between
210
+ * the cutover and the invalidation - which the fingerprint alone cannot say,
211
+ * and which would otherwise leave the previous revision's pages cached.
212
+ */
213
+ function publishingKey(fingerprint) {
214
+ return `web/publishing/${fingerprint}.json`;
215
+ }
216
+ async function markPublishing(ctx, source, fingerprint) {
217
+ await ctx.clients.s3.putObject(ctx.names.bucket, publishingKey(fingerprint), JSON.stringify({ hash: source.hash, startedAt: source.startedAt.toISOString() }), 'application/json');
218
+ }
219
+ async function publishUnfinished(ctx, fingerprint) {
220
+ return Boolean(await ctx.clients.s3.getObjectText(ctx.names.bucket, publishingKey(fingerprint)));
221
+ }
222
+ /** Best-effort: a note left behind costs one extra probe and `/*`, never correctness. */
223
+ async function clearPublishing(ctx, fingerprint) {
224
+ await ctx.clients.s3
225
+ .deleteObject(ctx.names.bucket, publishingKey(fingerprint))
226
+ .catch(() => undefined);
227
+ }
228
+ function webFunctionBlock(ctx, revision, fingerprint, artifact) {
229
+ const pinned = revision.buildConfig?.codeConfig.s3Object;
230
+ return {
231
+ revisionId: revision.revisionId,
232
+ revisionArn: revision.revisionArn,
233
+ fingerprint,
234
+ // A revision pins its own artifact version, which the service echoes back
235
+ // in its buildConfig; that pin is what rollback must reproduce, not a
236
+ // version this build uploaded for a revision it never created.
237
+ artifactKey: pinned?.key ?? artifact.artifactKey,
238
+ artifactVersionId: pinned?.versionId ?? artifact.artifactVersionId,
239
+ artifactSha256: artifact.artifactSha256,
240
+ settings: revisionSettings(ctx),
241
+ endpointName: String(ctx.state.resources['web-function']?.endpointName ?? 'site'),
242
+ endpointDomainName: String(ctx.state.resources['web-function']?.endpointDomainName ?? ''),
243
+ };
244
+ }
245
+ /**
246
+ * Record an outcome. The manifest is written only when the outcome is known,
247
+ * and a failed one keeps the hash's last verified `webFunction` block.
248
+ */
249
+ export async function recordManifest(ctx, source, status, webFunction, message) {
250
+ const finishedAt = new Date();
251
+ const manifest = {
252
+ hash: source.hash,
253
+ sourceKey: source.sourceKey,
254
+ status,
255
+ startedAt: source.startedAt.toISOString(),
256
+ finishedAt: finishedAt.toISOString(),
257
+ durationMs: finishedAt.getTime() - source.startedAt.getTime(),
258
+ microvmId: source.microvmId,
259
+ ...(message ? { message } : {}),
260
+ ...(webFunction ? { webFunction } : {}),
261
+ };
262
+ return writeManifest(ctx, manifest, { ssr: true });
263
+ }
264
+ /**
265
+ * The build and service configuration a revision is created with. The entry
266
+ * point travels as `AWS_LAMBDA_NODEJS_ENTRYPOINT` inside `environmentVariables`
267
+ * (the API has no entry-point field), which is why config rejects that name.
268
+ */
269
+ function revisionConfig(ctx, s3Object) {
270
+ const settings = revisionSettings(ctx);
271
+ return {
272
+ buildConfig: {
273
+ codeConfig: { s3Object },
274
+ runtimeConfig: { runtime: WEB_FUNCTION_RUNTIME },
275
+ },
276
+ serviceConfig: {
277
+ executionRoleArn: webRoleArn(ctx),
278
+ timeoutSeconds: settings.timeoutSeconds,
279
+ maxConcurrencyPerEnvironment: settings.maxConcurrencyPerEnvironment,
280
+ environmentVariables: settings.env,
281
+ telemetryConfig: { loggingConfig: { logGroup: ctx.names.webLogGroup } },
282
+ },
283
+ };
284
+ }
285
+ /** Create a revision carrying the settings this deploy resolved. */
286
+ async function createRevision(ctx, s3Object, fingerprint) {
287
+ return ctx.clients.lambdaWeb.createWebFunctionRevision({
288
+ functionName: ctx.names.webFunction,
289
+ description: `${FINGERPRINT_PREFIX}${fingerprint}`,
290
+ ...revisionConfig(ctx, s3Object),
291
+ });
292
+ }
293
+ /**
294
+ * Re-create a captured revision from its own config. A `LatestRevision`
295
+ * endpoint has already cut over by the time a probe runs, so this is how a
296
+ * deploy that cannot verify its revision puts service back. The request
297
+ * carries the captured configuration verbatim - it is what the service
298
+ * returned, so it is what the service accepts - with the current settings
299
+ * filling anything the capture lacks.
300
+ */
301
+ async function restoreRevision(ctx, previous, fallbackS3Object, pacing) {
302
+ const captured = revisionConfig(ctx, fallbackS3Object);
303
+ const revision = await ctx.clients.lambdaWeb.createWebFunctionRevision({
304
+ functionName: ctx.names.webFunction,
305
+ ...(previous.description !== undefined ? { description: previous.description } : {}),
306
+ buildConfig: previous.buildConfig ?? captured.buildConfig,
307
+ serviceConfig: previous.serviceConfig ?? captured.serviceConfig,
308
+ });
309
+ const settled = await awaitRevisionActive(ctx, revision);
310
+ if (!/^Active$/i.test(settled.state))
311
+ return undefined;
312
+ return (await waitForServing(ctx, pacing)) ? settled : undefined;
313
+ }
314
+ /** The newest manifest that carries a `webFunction` block, for rollback advice. */
315
+ async function newestWebFunctionHash(ctx) {
316
+ const objects = await ctx.clients.s3.listObjects(ctx.names.bucket, 'build/manifests/');
317
+ const manifests = [];
318
+ for (const object of objects) {
319
+ const text = await ctx.clients.s3.getObjectText(ctx.names.bucket, object.key);
320
+ if (!text)
321
+ continue;
322
+ try {
323
+ manifests.push(JSON.parse(text));
324
+ }
325
+ catch {
326
+ // One corrupt manifest must not hide the good ones.
327
+ }
328
+ }
329
+ manifests.sort((a, b) => a.finishedAt > b.finishedAt ? -1 : a.finishedAt < b.finishedAt ? 1 : 0);
330
+ return manifests.find((manifest) => manifest.webFunction)?.hash;
331
+ }
332
+ /** What to run when nothing serves and nothing was captured to restore from. */
333
+ async function rollbackAdvice(ctx) {
334
+ const target = await newestWebFunctionHash(ctx);
335
+ return (`Run \`blogwright rollback ${target ?? '<hash>'} ${ctx.env}\` with the newest hash ` +
336
+ `that shows a revision in \`blogwright history ${ctx.env}\`. ` +
337
+ `Logs: ${ctx.names.webLogGroup}`);
338
+ }
339
+ /**
340
+ * Publish a revision for a finished build: compare fingerprints, capture what
341
+ * is serving, create and verify, restore on a failed readiness probe, and only
342
+ * then touch the cache and the stale site keys.
343
+ */
344
+ export async function publishWebRevision(ctx, source, result, pacing = {}) {
345
+ const fingerprint = revisionFingerprint(ctx, result.artifactSha256);
346
+ const serving = await servingRevision(ctx);
347
+ // What follows a revision that is verified to serve: only now are the cache
348
+ // and the stale site keys touched, and only then is the publish noted done.
349
+ const complete = async (revision, created) => {
350
+ const invalidation = await invalidateAll(ctx);
351
+ const staleKeysDeleted = await deleteStaleKeys(ctx, result.staleStaticKeys);
352
+ const manifest = await recordManifest(ctx, source, 'succeeded', webFunctionBlock(ctx, revision, fingerprint, result));
353
+ await clearPublishing(ctx, fingerprint);
354
+ ctx.logger.ok(`revision ${revision.revisionId} is serving`);
355
+ return { manifest, created, revisionId: revision.revisionId, invalidation, staleKeysDeleted };
356
+ };
357
+ if (serving && revisionFingerprintOf(serving) === fingerprint) {
358
+ if (await publishUnfinished(ctx, fingerprint)) {
359
+ // An earlier publish created this revision and stopped before the cache
360
+ // was invalidated, so it is finished here rather than read as unchanged:
361
+ // the revision was never verified and the old pages are still cached.
362
+ ctx.logger.warn(`revision ${serving.revisionId} matches this deploy, but the publish that created it ` +
363
+ 'never finished - verifying it and invalidating before it is recorded');
364
+ if (await waitForServing(ctx, pacing))
365
+ return complete(serving, false);
366
+ await recordManifest(ctx, source, 'failed', undefined, `revision ${serving.revisionId} did not serve within ` +
367
+ `${formatDuration(READINESS_PROBE_TIMEOUT_MS)}`);
368
+ throw new Error(`Web Function revision ${serving.revisionId} is Active but did not serve within ` +
369
+ `${formatDuration(READINESS_PROBE_TIMEOUT_MS)}, and the revision it replaced was ` +
370
+ `not captured, so the endpoint is NOT serving. ${await rollbackAdvice(ctx)}`);
371
+ }
372
+ ctx.logger.ok(`revision ${serving.revisionId} already matches this deploy - nothing to create`);
373
+ // No new revision, so the invalidation is limited to the static assets this
374
+ // build actually changed; `invalidationMaxPaths` applies here and nowhere else.
375
+ const invalidation = await invalidateChanged(ctx, source.hash);
376
+ const staleKeysDeleted = await deleteStaleKeys(ctx, result.staleStaticKeys);
377
+ const manifest = await recordManifest(ctx, source, 'succeeded', webFunctionBlock(ctx, serving, fingerprint, result));
378
+ return {
379
+ manifest,
380
+ created: false,
381
+ revisionId: serving.revisionId,
382
+ invalidation,
383
+ staleKeysDeleted,
384
+ };
385
+ }
386
+ const previous = serving;
387
+ ctx.logger.step(`creating Web Function revision (fingerprint ${fingerprint.slice(0, 12)}…)`);
388
+ const builtS3Object = {
389
+ bucket: ctx.names.bucket,
390
+ key: result.artifactKey,
391
+ versionId: result.artifactVersionId,
392
+ };
393
+ await markPublishing(ctx, source, fingerprint);
394
+ const created = await createRevision(ctx, builtS3Object, fingerprint);
395
+ const settled = await awaitRevisionActive(ctx, created);
396
+ if (!/^Active$/i.test(settled.state)) {
397
+ const reason = revisionErrors(settled);
398
+ await recordManifest(ctx, source, 'failed', undefined, `revision ${settled.revisionId} failed: ${reason}`);
399
+ // A Failed revision never took traffic, so there is nothing left to finish.
400
+ await clearPublishing(ctx, fingerprint);
401
+ throw new Error(`Web Function revision ${settled.revisionId} failed (${reason}). ` +
402
+ `${previous
403
+ ? `The previous revision ${previous.revisionId} keeps serving`
404
+ : 'No revision is serving'}; nothing was invalidated. Logs: ${ctx.names.webLogGroup}`);
405
+ }
406
+ if (await waitForServing(ctx, pacing))
407
+ return complete(settled, true);
408
+ // The readiness probe did not pass. The endpoint has already cut over, so
409
+ // restore service from the captured revision rather than leave a dead one
410
+ // behind it - and leave the cache and the stale keys untouched, so whatever
411
+ // is still cached keeps serving.
412
+ const restored = previous
413
+ ? await restoreRevision(ctx, previous, builtS3Object, pacing)
414
+ : undefined;
415
+ const restoreNote = restored
416
+ ? `restored revision ${restored.revisionId} from its captured configuration`
417
+ : 'the restore revision did not serve either, so the endpoint is NOT serving';
418
+ await recordManifest(ctx, source, 'failed', undefined, `revision ${settled.revisionId} did not serve within ` +
419
+ `${formatDuration(READINESS_PROBE_TIMEOUT_MS)}; ${restoreNote}`);
420
+ // Restored, the rejected revision no longer serves and has nothing to finish.
421
+ // Not restored, it is what the endpoint routes to: the note stays, so a retry
422
+ // verifies it instead of reading it as an unchanged, finished publish.
423
+ if (restored)
424
+ await clearPublishing(ctx, fingerprint);
425
+ const advice = restored
426
+ ? 'The cache was not invalidated and no stale site keys were deleted, so cached pages keep ' +
427
+ `serving. Logs: ${ctx.names.webLogGroup}`
428
+ : await rollbackAdvice(ctx);
429
+ throw new Error(`Web Function revision ${settled.revisionId} is Active but did not serve within ` +
430
+ `${formatDuration(READINESS_PROBE_TIMEOUT_MS)}; ${restoreNote}. ${advice}`);
431
+ }
432
+ /**
433
+ * Rollback's artifact path: reproduce a recorded revision from the artifact
434
+ * version and settings in that hash's manifest, without a rebuild. Skips
435
+ * creation when the recorded fingerprint is already what serves, and probes
436
+ * and invalidates exactly as a deploy does.
437
+ */
438
+ export async function publishRecordedRevision(ctx, source, recorded, pacing = {}) {
439
+ const serving = await servingRevision(ctx);
440
+ if (serving && revisionFingerprintOf(serving) === recorded.fingerprint) {
441
+ ctx.logger.ok(`revision ${serving.revisionId} already matches ${source.hash} - nothing to create`);
442
+ const invalidation = await invalidateAll(ctx);
443
+ // The serving revision may be a later re-creation of the recorded one (a
444
+ // restore, an earlier rollback), so the manifest names what serves now.
445
+ const manifest = await recordManifest(ctx, source, 'succeeded', {
446
+ ...recorded,
447
+ revisionId: serving.revisionId,
448
+ revisionArn: serving.revisionArn,
449
+ });
450
+ await clearPublishing(ctx, recorded.fingerprint);
451
+ return {
452
+ manifest,
453
+ created: false,
454
+ revisionId: serving.revisionId,
455
+ invalidation,
456
+ staleKeysDeleted: 0,
457
+ };
458
+ }
459
+ const previous = serving;
460
+ const recordedS3Object = {
461
+ bucket: ctx.names.bucket,
462
+ key: recorded.artifactKey,
463
+ versionId: recorded.artifactVersionId,
464
+ };
465
+ ctx.logger.step(`creating revision from ${source.hash}'s recorded settings`);
466
+ // Noted like a deploy's publish: a deploy of the same fingerprint that follows
467
+ // an interrupted rollback must not read this revision as finished.
468
+ await markPublishing(ctx, source, recorded.fingerprint);
469
+ const created = await ctx.clients.lambdaWeb.createWebFunctionRevision({
470
+ functionName: ctx.names.webFunction,
471
+ description: `${FINGERPRINT_PREFIX}${recorded.fingerprint}`,
472
+ buildConfig: {
473
+ codeConfig: { s3Object: recordedS3Object },
474
+ runtimeConfig: { runtime: WEB_FUNCTION_RUNTIME },
475
+ },
476
+ serviceConfig: {
477
+ executionRoleArn: webRoleArn(ctx),
478
+ timeoutSeconds: recorded.settings.timeoutSeconds,
479
+ maxConcurrencyPerEnvironment: recorded.settings.maxConcurrencyPerEnvironment,
480
+ environmentVariables: recorded.settings.env,
481
+ telemetryConfig: { loggingConfig: { logGroup: ctx.names.webLogGroup } },
482
+ },
483
+ });
484
+ const settled = await awaitRevisionActive(ctx, created);
485
+ if (!/^Active$/i.test(settled.state)) {
486
+ const reason = revisionErrors(settled);
487
+ await recordManifest(ctx, source, 'failed', undefined, `rollback revision ${settled.revisionId} failed: ${reason}`);
488
+ await clearPublishing(ctx, recorded.fingerprint);
489
+ throw new Error(`rollback revision ${settled.revisionId} failed (${reason}); ` +
490
+ `${previous ? `revision ${previous.revisionId} keeps serving` : 'no revision is serving'}. Logs: ${ctx.names.webLogGroup}`);
491
+ }
492
+ if (!(await waitForServing(ctx, pacing))) {
493
+ const restored = previous
494
+ ? await restoreRevision(ctx, previous, recordedS3Object, pacing)
495
+ : undefined;
496
+ await recordManifest(ctx, source, 'failed', undefined, `rollback revision ${settled.revisionId} did not serve`);
497
+ if (restored)
498
+ await clearPublishing(ctx, recorded.fingerprint);
499
+ throw new Error(`rollback revision ${settled.revisionId} is Active but did not serve` +
500
+ (restored ? `; restored revision ${restored.revisionId}` : ' and no restore was possible') +
501
+ `. Logs: ${ctx.names.webLogGroup}`);
502
+ }
503
+ const invalidation = await invalidateAll(ctx);
504
+ const manifest = await recordManifest(ctx, source, 'succeeded', {
505
+ ...recorded,
506
+ revisionId: settled.revisionId,
507
+ revisionArn: settled.revisionArn,
508
+ });
509
+ await clearPublishing(ctx, recorded.fingerprint);
510
+ ctx.logger.ok(`revision ${settled.revisionId} is serving`);
511
+ return {
512
+ manifest,
513
+ created: true,
514
+ revisionId: settled.revisionId,
515
+ invalidation,
516
+ staleKeysDeleted: 0,
517
+ };
518
+ }
519
+ /**
520
+ * `seo` is inert in SSR mode - the application owns robots.txt and
521
+ * sitemap.xml - so a written, non-`off` value warns once per deploy. The check
522
+ * reads the RAW config document: the merged defaults are `auto`, so reading
523
+ * the merged config would warn on every SSR deploy.
524
+ */
525
+ export function warnAboutSsrSeo(ctx) {
526
+ const raw = ctx.configDocument['seo'];
527
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
528
+ return;
529
+ const written = raw;
530
+ const inert = Object.entries(written).some(([key, value]) => {
531
+ if (value === undefined)
532
+ return false;
533
+ if (key === 'robots' || key === 'sitemap')
534
+ return value !== 'off';
535
+ return true;
536
+ });
537
+ if (!inert)
538
+ return;
539
+ ctx.logger.warn('config.seo is inert in SSR mode: the application owns the robots.txt and sitemap.xml routes');
540
+ }
541
+ /** Refuse a preview command on an environment that configures SSR. */
542
+ export function assertNotSsrForPreviews(ctx) {
543
+ if (!ctx.config.ssr)
544
+ return;
545
+ throw new Error(`the preview stack does not support SSR ("${ctx.env}" sets config.ssr): an SSR preview ` +
546
+ 'needs its own endpoint, which is a separate design. Remove the ssr block from the ' +
547
+ 'preview config, or preview a static branch.');
548
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blogwright",
3
- "version": "0.4.0-beta.4",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "blogwright": "./dist/bin.js",
@@ -17,8 +17,8 @@
17
17
  }
18
18
  },
19
19
  "dependencies": {
20
- "blogwright-core": "0.4.0-beta.4",
21
- "blogwright-pds": "0.4.0-beta.4",
20
+ "blogwright-core": "0.4.0",
21
+ "blogwright-pds": "0.4.0",
22
22
  "fflate": "^0.8.3"
23
23
  },
24
24
  "devDependencies": {
@@ -28,7 +28,7 @@
28
28
  "typescript": "^6.0.3",
29
29
  "vitest": "^4.1.9"
30
30
  },
31
- "description": "Full operations for a blog site on AWS: S3 + CloudFront hosting with builds in a Lambda MicroVM, PR previews, GitHub-OIDC CI deploys, and standard.site (AT Protocol) publishing",
31
+ "description": "Deploy static sites, single-page apps and server-rendered apps to AWS: S3 + CloudFront hosting, builds in a Lambda MicroVM, Lambda Web Functions for SSR, PR previews, GitHub-OIDC CI deploys, and plugins for analytics and standard.site publishing",
32
32
  "keywords": [
33
33
  "astro",
34
34
  "aws",