@kungfu-tech/buildchain 2.8.2 → 2.8.3-alpha.1
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/bin/buildchain.mjs +3 -0
- package/dist/site/buildchain-contract.json +7 -7
- package/dist/site/buildchain-site.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/node-api-registry.json +1 -1
- package/dist/site/site-manifest.json +1 -1
- package/docs/web-surface-deployments.md +53 -4
- package/fixtures/web-surface-shaped/README.md +2 -1
- package/package.json +1 -1
- package/packages/core/release-passport.js +76 -3
- package/scripts/web-surface-core.mjs +431 -3
- package/scripts/web-surface-release-feedback.mjs +16 -0
- package/scripts/web-surface.mjs +47 -1
package/bin/buildchain.mjs
CHANGED
|
@@ -81,6 +81,7 @@ function usage() {
|
|
|
81
81
|
[--kfd-3-prebuild-witness-json <json-or-path>]...
|
|
82
82
|
[--kfd-3-artifact-witness-json <json-or-path>]...
|
|
83
83
|
[--kfd-3-artifact-verify-cmd <command>]
|
|
84
|
+
[--base-passport-json <json-or-path>] [--require-base-kfd]
|
|
84
85
|
[--release-extra-json <json-or-path>]
|
|
85
86
|
[--publish-json <json-or-path>] [--output-dir <dir>] [--json]
|
|
86
87
|
buildchain verify release-passport <file-or-url> [--json]
|
|
@@ -744,6 +745,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
744
745
|
kfd3PrebuildWitnessJsons: readRepeatedFlag(collectArgs, "kfd-3-prebuild-witness-json"),
|
|
745
746
|
kfd3ArtifactWitnessJsons: readRepeatedFlag(collectArgs, "kfd-3-artifact-witness-json"),
|
|
746
747
|
kfd3ArtifactVerifyCommand: readFlag(collectArgs, "kfd-3-artifact-verify-cmd", ""),
|
|
748
|
+
basePassportJson: readFlag(collectArgs, "base-passport-json", ""),
|
|
749
|
+
requireBaseKfd: readBooleanFlag(collectArgs, "require-base-kfd"),
|
|
747
750
|
releaseJsonExtra: readFlag(collectArgs, "release-extra-json", ""),
|
|
748
751
|
publishJson: readFlag(collectArgs, "publish-json", ""),
|
|
749
752
|
workflow,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"product": {
|
|
5
5
|
"name": "Buildchain",
|
|
6
6
|
"package": "@kungfu-tech/buildchain",
|
|
7
|
-
"version": "2.8.
|
|
7
|
+
"version": "2.8.3-alpha.1",
|
|
8
8
|
"repository": "https://github.com/kungfu-systems/buildchain"
|
|
9
9
|
},
|
|
10
10
|
"majorLine": "v2",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"release-state SHA is recorded as a durable audit entrance"
|
|
194
194
|
],
|
|
195
195
|
"breakingDigest": "sha256:b627e1b2ece9b6049517a942c5139e342e6077cdd3d8962d61cef8481b1f70ad",
|
|
196
|
-
"auditDigest": "sha256:
|
|
196
|
+
"auditDigest": "sha256:40c556f7df33a3c07ba6009c4cd283fad97b82db7026f2dc6db9394efbc1431c"
|
|
197
197
|
},
|
|
198
198
|
{
|
|
199
199
|
"contractVersion": 1,
|
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
"prose-only public claims downgrade the release trust passport audit"
|
|
243
243
|
],
|
|
244
244
|
"breakingDigest": "sha256:f0b636eb925ddbf45ff1a8872454ab7b4dd98857cac12bb75217830130e62a16",
|
|
245
|
-
"auditDigest": "sha256:
|
|
245
|
+
"auditDigest": "sha256:40c556f7df33a3c07ba6009c4cd283fad97b82db7026f2dc6db9394efbc1431c"
|
|
246
246
|
},
|
|
247
247
|
{
|
|
248
248
|
"contractVersion": 1,
|
|
@@ -298,7 +298,7 @@
|
|
|
298
298
|
"CLI commands are stable within the major line unless the contract major changes"
|
|
299
299
|
],
|
|
300
300
|
"breakingDigest": "sha256:097db3e67509bedda8c6548bf1edfbfcf57f7b5510520b6a7b7e078d7ec087c8",
|
|
301
|
-
"auditDigest": "sha256:
|
|
301
|
+
"auditDigest": "sha256:1f4b07ac623517209dbdee2992f94d95f7ee96e8f35f13ef620dad8da9d2a6e0"
|
|
302
302
|
},
|
|
303
303
|
{
|
|
304
304
|
"contractVersion": 1,
|
|
@@ -320,7 +320,7 @@
|
|
|
320
320
|
"manual entries carry source file digests so downstream sites and agents can detect stale hand-written documentation"
|
|
321
321
|
],
|
|
322
322
|
"breakingDigest": "sha256:7d0d2819e3a3e72989d9c57b5efe9d0bc0a79bc0f2c82a0c7b9d6c5a211a91f2",
|
|
323
|
-
"auditDigest": "sha256:
|
|
323
|
+
"auditDigest": "sha256:6f4d02f7852b46b293bbb3fb63c678a57dffdcb93598fd3bac5298796e624b63"
|
|
324
324
|
},
|
|
325
325
|
{
|
|
326
326
|
"contractVersion": 1,
|
|
@@ -342,9 +342,9 @@
|
|
|
342
342
|
"agents can discover supported Node APIs without importing internal file paths"
|
|
343
343
|
],
|
|
344
344
|
"breakingDigest": "sha256:48f925608d3e2131d90936b07dc2a30341204cae3e6e785c0f77d61ad755c945",
|
|
345
|
-
"auditDigest": "sha256:
|
|
345
|
+
"auditDigest": "sha256:c3745538a9b17829bf07db485247f8e86245736798b9e631a935e53073a382d0"
|
|
346
346
|
}
|
|
347
347
|
],
|
|
348
348
|
"compatibilityDigest": "sha256:36421b592a782decbfe31a7d8d42d70ce1100a56a2c4f4a0ccc21f0617dc72bf",
|
|
349
|
-
"contractDigest": "sha256:
|
|
349
|
+
"contractDigest": "sha256:5e122a3c90f0c025377d684a804871d49050f2138bb18ec2f70cadbcf442cc52"
|
|
350
350
|
}
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"path": "docs/web-surface-deployments.md",
|
|
146
146
|
"plane": "use",
|
|
147
147
|
"exists": true,
|
|
148
|
-
"digest": "sha256:
|
|
148
|
+
"digest": "sha256:796e891819f03f0a7ac91f56f1bb74d9e3443d8bb0bee33a3cd053d70bde6a62"
|
|
149
149
|
},
|
|
150
150
|
{
|
|
151
151
|
"id": "infra-contract",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"title": "Web surface deployments",
|
|
108
108
|
"path": "docs/web-surface-deployments.md",
|
|
109
109
|
"plane": "use",
|
|
110
|
-
"digest": "sha256:
|
|
110
|
+
"digest": "sha256:796e891819f03f0a7ac91f56f1bb74d9e3443d8bb0bee33a3cd053d70bde6a62"
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
"id": "infra-contract",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"specifier": "@kungfu-tech/buildchain/release-passport",
|
|
63
63
|
"export": "./release-passport",
|
|
64
64
|
"target": "./packages/core/release-passport.js",
|
|
65
|
-
"digest": "sha256:
|
|
65
|
+
"digest": "sha256:40c556f7df33a3c07ba6009c4cd283fad97b82db7026f2dc6db9394efbc1431c"
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
"specifier": "@kungfu-tech/buildchain/release-propagation",
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
"path": "docs/web-surface-deployments.md",
|
|
131
131
|
"plane": "use",
|
|
132
132
|
"exists": true,
|
|
133
|
-
"digest": "sha256:
|
|
133
|
+
"digest": "sha256:796e891819f03f0a7ac91f56f1bb74d9e3443d8bb0bee33a3cd053d70bde6a62"
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
136
|
"id": "infra-contract",
|
|
@@ -64,10 +64,10 @@ secret_refs = ["AWS_ROLE_ARN"]
|
|
|
64
64
|
### Multi-Surface Host Mapping
|
|
65
65
|
|
|
66
66
|
Some site repositories publish more than one first-class web surface from the
|
|
67
|
-
same artifact. For example,
|
|
68
|
-
|
|
69
|
-
navigation paths; staging
|
|
70
|
-
behavior for each surface.
|
|
67
|
+
same artifact. For example, `site-libkungfu-dev` has a hub plus separate
|
|
68
|
+
hostnames for core, Buildchain, and Kung Fu Decisions. These are not just
|
|
69
|
+
navigation paths; staging, production preflight, and post-deploy health checks
|
|
70
|
+
must verify host-level behavior for each surface.
|
|
71
71
|
|
|
72
72
|
Declare named surfaces with per-channel URLs:
|
|
73
73
|
|
|
@@ -89,6 +89,12 @@ path = "/buildchain/"
|
|
|
89
89
|
production_url = "https://buildchain.libkungfu.dev"
|
|
90
90
|
staging_url = "https://buildchain.staging.libkungfu.dev"
|
|
91
91
|
preview_url_pattern = "https://buildchain-{alias}.preview.libkungfu.dev"
|
|
92
|
+
|
|
93
|
+
[surfaces.kfd]
|
|
94
|
+
path = "/kfd/"
|
|
95
|
+
production_url = "https://kfd.libkungfu.dev"
|
|
96
|
+
staging_url = "https://kfd.staging.libkungfu.dev"
|
|
97
|
+
preview_url_pattern = "https://kfd-{alias}.preview.libkungfu.dev"
|
|
92
98
|
```
|
|
93
99
|
|
|
94
100
|
Buildchain resolves every `(channel, surface)` pair. A preview alias such as
|
|
@@ -98,6 +104,7 @@ Buildchain resolves every `(channel, surface)` pair. A preview alias such as
|
|
|
98
104
|
hub: https://pr-12.preview.libkungfu.dev
|
|
99
105
|
core: https://core-pr-12.preview.libkungfu.dev
|
|
100
106
|
buildchain: https://buildchain-pr-12.preview.libkungfu.dev
|
|
107
|
+
kfd: https://kfd-pr-12.preview.libkungfu.dev
|
|
101
108
|
```
|
|
102
109
|
|
|
103
110
|
When `surfaces` is omitted, Buildchain preserves the legacy single-surface
|
|
@@ -325,6 +332,48 @@ and exits non-zero after writing the result JSON. Buildchain records secret
|
|
|
325
332
|
reference names only; the runner must provide the AWS CLI and credentials
|
|
326
333
|
outside Buildchain, typically through OIDC and the declared `secret_refs`.
|
|
327
334
|
|
|
335
|
+
## Production Preflight And Health
|
|
336
|
+
|
|
337
|
+
Production promotion is not just `deploy-apply --channel production`. Before a
|
|
338
|
+
live production apply, the reusable workflow runs:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
node scripts/web-surface.mjs \
|
|
342
|
+
--mode production-preflight \
|
|
343
|
+
--cwd fixtures/web-surface-shaped \
|
|
344
|
+
--plan .buildchain/web-surface-production-plan.json \
|
|
345
|
+
--execute true \
|
|
346
|
+
--output .buildchain/web-surface-production-preflight.json
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
The production preflight checks that:
|
|
350
|
+
|
|
351
|
+
- `channels.production` is canonical and indexable;
|
|
352
|
+
- every surface has concrete production bucket and CloudFront targets;
|
|
353
|
+
- every production surface URL is HTTPS;
|
|
354
|
+
- the production AWS role can inspect the declared bucket and distribution;
|
|
355
|
+
- CloudFront aliases cover every surface host, including product hosts such as
|
|
356
|
+
`kfd.libkungfu.dev`;
|
|
357
|
+
- DNS resolves for every surface host.
|
|
358
|
+
|
|
359
|
+
After production apply, the workflow runs:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
node scripts/web-surface.mjs \
|
|
363
|
+
--mode health-check \
|
|
364
|
+
--cwd fixtures/web-surface-shaped \
|
|
365
|
+
--result .buildchain/web-surface-production-apply.json \
|
|
366
|
+
--output .buildchain/web-surface-production-health.json
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The health check fetches every surface URL from the apply result and fails
|
|
370
|
+
production if a response is unreachable, returns an unexpected status, or still
|
|
371
|
+
sends `x-robots-tag: noindex`. It also verifies that each surface binding
|
|
372
|
+
recorded a deployment manifest pointer. The production release passport embeds
|
|
373
|
+
the deploy plan, apply result, production preflight, and health check so a
|
|
374
|
+
reviewer or agent can audit why the production site changed and whether every
|
|
375
|
+
declared host was actually covered.
|
|
376
|
+
|
|
328
377
|
## Cleanup Plans
|
|
329
378
|
|
|
330
379
|
Preview cleanup is an auditable cleanup contract. It can run as a dry-run plan,
|
|
@@ -7,13 +7,14 @@ It proves that `project.type = "web-surface"` can declare preview, staging, and
|
|
|
7
7
|
production channels, named surface host mappings, deterministic deployment
|
|
8
8
|
manifests, and dry-run deploy and cleanup plans without touching AWS.
|
|
9
9
|
|
|
10
|
-
The fixture is shaped like a `site-libkungfu-dev` repository with
|
|
10
|
+
The fixture is shaped like a `site-libkungfu-dev` repository with four
|
|
11
11
|
first-class surfaces:
|
|
12
12
|
|
|
13
13
|
```text
|
|
14
14
|
hub https://libkungfu.dev
|
|
15
15
|
core https://core.libkungfu.dev
|
|
16
16
|
buildchain https://buildchain.libkungfu.dev
|
|
17
|
+
kfd https://kfd.libkungfu.dev
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
For preview and staging, Buildchain resolves the same surface names to the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kungfu-tech/buildchain",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.3-alpha.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
|
|
6
6
|
"repository": "https://github.com/kungfu-systems/buildchain",
|
|
@@ -281,6 +281,73 @@ function parseJsonInputWithMeta(value, fallback = undefined) {
|
|
|
281
281
|
};
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
function mergeAuthoritativePassportBase(passport, basePassport = undefined, { requireKfd = false } = {}) {
|
|
285
|
+
if (!basePassport) {
|
|
286
|
+
if (requireKfd) {
|
|
287
|
+
throw new Error("release passport base with KFD evidence is required");
|
|
288
|
+
}
|
|
289
|
+
return passport;
|
|
290
|
+
}
|
|
291
|
+
if (typeof basePassport !== "object" || Array.isArray(basePassport)) {
|
|
292
|
+
throw new Error("release passport base must be a JSON object");
|
|
293
|
+
}
|
|
294
|
+
const kfdKeys = ["kfd-1", "kfd-2", "kfd-3"];
|
|
295
|
+
const missingKfd = kfdKeys.filter((key) => (
|
|
296
|
+
!basePassport[key] ||
|
|
297
|
+
typeof basePassport[key] !== "object" ||
|
|
298
|
+
Array.isArray(basePassport[key])
|
|
299
|
+
));
|
|
300
|
+
if (requireKfd && missingKfd.length > 0) {
|
|
301
|
+
throw new Error(`release passport base is missing required KFD sections: ${missingKfd.join(", ")}`);
|
|
302
|
+
}
|
|
303
|
+
const merged = {
|
|
304
|
+
...passport,
|
|
305
|
+
release: {
|
|
306
|
+
...(passport.release && typeof passport.release === "object" && !Array.isArray(passport.release) ? passport.release : {}),
|
|
307
|
+
...(basePassport.release && typeof basePassport.release === "object" && !Array.isArray(basePassport.release) ? basePassport.release : {}),
|
|
308
|
+
},
|
|
309
|
+
evidence: {
|
|
310
|
+
...(passport.evidence && typeof passport.evidence === "object" && !Array.isArray(passport.evidence) ? passport.evidence : {}),
|
|
311
|
+
kfd1: basePassport.evidence?.kfd1 || passport.evidence?.kfd1 || "",
|
|
312
|
+
kfd2: basePassport.evidence?.kfd2 || passport.evidence?.kfd2 || "",
|
|
313
|
+
kfd3: basePassport.evidence?.kfd3 || passport.evidence?.kfd3 || "",
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
for (const key of ["versionImpact", "surfaceImpacts"]) {
|
|
317
|
+
if (basePassport[key] !== undefined) {
|
|
318
|
+
merged[key] = basePassport[key];
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (const key of kfdKeys) {
|
|
322
|
+
if (basePassport[key] && typeof basePassport[key] === "object" && !Array.isArray(basePassport[key])) {
|
|
323
|
+
merged[key] = basePassport[key];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return merged;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function mergeAuthoritativeImpactBase(impact, basePassport = undefined) {
|
|
330
|
+
if (!basePassport || typeof basePassport !== "object" || Array.isArray(basePassport)) {
|
|
331
|
+
return impact;
|
|
332
|
+
}
|
|
333
|
+
const merged = { ...impact };
|
|
334
|
+
if (basePassport.versionImpact && typeof basePassport.versionImpact === "object" && !Array.isArray(basePassport.versionImpact)) {
|
|
335
|
+
merged.versionImpact = {
|
|
336
|
+
...(impact.versionImpact && typeof impact.versionImpact === "object" && !Array.isArray(impact.versionImpact) ? impact.versionImpact : {}),
|
|
337
|
+
...basePassport.versionImpact,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (Array.isArray(basePassport.surfaceImpacts)) {
|
|
341
|
+
merged.surfaceImpacts = basePassport.surfaceImpacts;
|
|
342
|
+
}
|
|
343
|
+
for (const key of ["classification", "breaking", "security", "migrationRequired", "summary"]) {
|
|
344
|
+
if (basePassport[key] !== undefined) {
|
|
345
|
+
merged[key] = basePassport[key];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return merged;
|
|
349
|
+
}
|
|
350
|
+
|
|
284
351
|
function parseJsonCommandOutput({ command = "", cwd = process.cwd(), label = "command" } = {}) {
|
|
285
352
|
const normalized = String(command || "").trim();
|
|
286
353
|
if (!normalized) {
|
|
@@ -1054,6 +1121,8 @@ export function collectGitHubReleasePassport({
|
|
|
1054
1121
|
kfd3PrebuildWitnessJsons = [],
|
|
1055
1122
|
kfd3ArtifactWitnessJsons = [],
|
|
1056
1123
|
kfd3ArtifactVerifyCommand = "",
|
|
1124
|
+
basePassportJson = "",
|
|
1125
|
+
requireBaseKfd = false,
|
|
1057
1126
|
releaseJsonExtra = "",
|
|
1058
1127
|
publishJson = "",
|
|
1059
1128
|
workflow = {},
|
|
@@ -1088,6 +1157,7 @@ export function collectGitHubReleasePassport({
|
|
|
1088
1157
|
.filter(Boolean)
|
|
1089
1158
|
.map((witnessJson) => parseJsonInputWithMeta(witnessJson, undefined))
|
|
1090
1159
|
.filter((meta) => meta.value);
|
|
1160
|
+
const basePassportMeta = parseJsonInputWithMeta(basePassportJson, undefined);
|
|
1091
1161
|
const kfd3ArtifactCommandMeta = parseJsonCommandOutput({
|
|
1092
1162
|
command: kfd3ArtifactVerifyCommand,
|
|
1093
1163
|
cwd,
|
|
@@ -1107,7 +1177,10 @@ export function collectGitHubReleasePassport({
|
|
|
1107
1177
|
const resolvedOutputDir = path.resolve(cwd, outputDir);
|
|
1108
1178
|
const productMechanism = defaultProductMechanism({ repository, productName });
|
|
1109
1179
|
const artifactEvidence = createArtifactEvidence({ assets, repository, tag: resolvedTag, sourceSha, workflow });
|
|
1110
|
-
const impact =
|
|
1180
|
+
const impact = mergeAuthoritativeImpactBase(
|
|
1181
|
+
normalizeImpactLedger(impactMeta.value, { tag: resolvedTag, line, decision: "unknown" }),
|
|
1182
|
+
basePassportMeta.value,
|
|
1183
|
+
);
|
|
1111
1184
|
const agentIndex = defaultAgentIndex({ tag: resolvedTag });
|
|
1112
1185
|
const kfd1 = createKfd1ReleaseGateEvidence({
|
|
1113
1186
|
cwd,
|
|
@@ -1122,7 +1195,7 @@ export function collectGitHubReleasePassport({
|
|
|
1122
1195
|
artifactWitnessMetas: kfd3ArtifactWitnessMetas,
|
|
1123
1196
|
artifactCommandMeta: kfd3ArtifactCommandMeta.value ? kfd3ArtifactCommandMeta : undefined,
|
|
1124
1197
|
});
|
|
1125
|
-
const passport = createReleasePassport({
|
|
1198
|
+
const passport = mergeAuthoritativePassportBase(createReleasePassport({
|
|
1126
1199
|
cwd,
|
|
1127
1200
|
repository,
|
|
1128
1201
|
tag: resolvedTag,
|
|
@@ -1164,7 +1237,7 @@ export function collectGitHubReleasePassport({
|
|
|
1164
1237
|
publishEvidencePath: publishEvidenceMeta.path ? path.relative(resolvedOutputDir, publishEvidenceMeta.path).split(path.sep).join("/") : "",
|
|
1165
1238
|
transactionStatePath: transactionMeta.path ? path.relative(resolvedOutputDir, transactionMeta.path).split(path.sep).join("/") : "",
|
|
1166
1239
|
workflow,
|
|
1167
|
-
});
|
|
1240
|
+
}), basePassportMeta.value, { requireKfd: requireBaseKfd });
|
|
1168
1241
|
const checkReport = createReleaseCheckReport({
|
|
1169
1242
|
passport,
|
|
1170
1243
|
artifactEvidence,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import dns from "node:dns/promises";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
@@ -229,6 +230,14 @@ function cdnWildcardPath(value) {
|
|
|
229
230
|
return normalized ? `/${normalized}/*` : "/*";
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
function viewerWildcardPath(binding) {
|
|
234
|
+
try {
|
|
235
|
+
return cdnWildcardPath(new URL(binding.url).pathname);
|
|
236
|
+
} catch {
|
|
237
|
+
return cdnWildcardPath(binding.objectPrefix);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
232
241
|
function syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix }) {
|
|
233
242
|
const args = ["s3", "sync", artifactRoot, s3Uri(bucket, objectPrefix), "--delete"];
|
|
234
243
|
if (!objectPrefix) {
|
|
@@ -337,6 +346,66 @@ function assertConcreteAwsDeployConfig({ deployConfig, channel, operation }) {
|
|
|
337
346
|
}
|
|
338
347
|
}
|
|
339
348
|
|
|
349
|
+
function urlHost(value = "") {
|
|
350
|
+
try {
|
|
351
|
+
return new URL(value).hostname.toLowerCase();
|
|
352
|
+
} catch {
|
|
353
|
+
return "";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function wildcardHostMatches(alias = "", host = "") {
|
|
358
|
+
const normalizedAlias = String(alias || "").toLowerCase();
|
|
359
|
+
const normalizedHost = String(host || "").toLowerCase();
|
|
360
|
+
if (!normalizedAlias.startsWith("*.")) return false;
|
|
361
|
+
const suffix = normalizedAlias.slice(1);
|
|
362
|
+
return normalizedHost.endsWith(suffix) && normalizedHost.slice(0, -suffix.length).indexOf(".") === -1;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function aliasMatchesHost(aliases = [], host = "") {
|
|
366
|
+
const normalizedHost = String(host || "").toLowerCase();
|
|
367
|
+
return aliases.some((alias) => {
|
|
368
|
+
const normalizedAlias = String(alias || "").toLowerCase();
|
|
369
|
+
return normalizedAlias === normalizedHost || wildcardHostMatches(normalizedAlias, normalizedHost);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseCloudFrontAliases(stdout = "") {
|
|
374
|
+
if (!stdout) return [];
|
|
375
|
+
try {
|
|
376
|
+
const parsed = JSON.parse(stdout);
|
|
377
|
+
const aliases = parsed?.Distribution?.DistributionConfig?.Aliases?.Items ||
|
|
378
|
+
parsed?.DistributionConfig?.Aliases?.Items ||
|
|
379
|
+
parsed?.Aliases?.Items ||
|
|
380
|
+
parsed?.Aliases ||
|
|
381
|
+
[];
|
|
382
|
+
return Array.isArray(aliases) ? aliases : [];
|
|
383
|
+
} catch {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function defaultDnsResolver(host) {
|
|
389
|
+
try {
|
|
390
|
+
const answers = await dns.resolve(host);
|
|
391
|
+
return answers.map((address) => ({ type: "A", value: address }));
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error?.code === "ENODATA" || error?.code === "ENOTFOUND") {
|
|
394
|
+
const answers = await dns.resolve6(host);
|
|
395
|
+
return answers.map((address) => ({ type: "AAAA", value: address }));
|
|
396
|
+
}
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function preflightCheck({ name, status = "pass", details = {}, message = "" }) {
|
|
402
|
+
return { name, status, message, details };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function preflightStatus(checks) {
|
|
406
|
+
return checks.some((check) => check.status === "fail") ? "failed" : "passed";
|
|
407
|
+
}
|
|
408
|
+
|
|
340
409
|
function deployManifestKey(deployConfig, manifest) {
|
|
341
410
|
return joinS3Key(manifestPrefixFor(deployConfig), `${manifest.alias || manifest.channel}.json`);
|
|
342
411
|
}
|
|
@@ -694,7 +763,7 @@ export function applyWebSurfaceDeploy({
|
|
|
694
763
|
const primaryBinding = bindings[0] || {};
|
|
695
764
|
const objectPrefix = primaryBinding.objectPrefix || objectPrefixFor(deployConfig, resolvedPlan.manifest.alias || resolvedPlan.manifest.channel);
|
|
696
765
|
const manifestKey = primaryBinding.manifestKey || deployManifestKey(deployConfig, resolvedPlan.manifest);
|
|
697
|
-
const invalidationPaths = bindings.flatMap((binding) => [
|
|
766
|
+
const invalidationPaths = bindings.flatMap((binding) => [viewerWildcardPath(binding), cdnPath(binding.manifestKey)]);
|
|
698
767
|
const operationResults = runAdapterOperations({ operations, dryRun, commandRunner });
|
|
699
768
|
return {
|
|
700
769
|
schemaVersion: 1,
|
|
@@ -722,6 +791,365 @@ export function applyWebSurfaceDeploy({
|
|
|
722
791
|
};
|
|
723
792
|
}
|
|
724
793
|
|
|
794
|
+
export async function preflightWebSurfaceProduction({
|
|
795
|
+
cwd = process.cwd(),
|
|
796
|
+
plan = null,
|
|
797
|
+
execute = false,
|
|
798
|
+
commandRunner = defaultCommandRunner,
|
|
799
|
+
dnsResolver = defaultDnsResolver,
|
|
800
|
+
checkedAt = new Date().toISOString(),
|
|
801
|
+
} = {}) {
|
|
802
|
+
const resolvedPlan = plan ? assertDeployPlan(plan) : planWebSurfaceDeploy({
|
|
803
|
+
cwd,
|
|
804
|
+
channel: "production",
|
|
805
|
+
sourceSha: "0".repeat(40),
|
|
806
|
+
artifactHash: "0".repeat(64),
|
|
807
|
+
dryRun: true,
|
|
808
|
+
deployedAt: checkedAt,
|
|
809
|
+
});
|
|
810
|
+
if (resolvedPlan.channel !== "production") {
|
|
811
|
+
throw new Error("web-surface production preflight requires a production deploy plan");
|
|
812
|
+
}
|
|
813
|
+
const loadedConfig = loadBuildchainConfig(cwd);
|
|
814
|
+
const config = assertWebSurfaceConfig(loadedConfig);
|
|
815
|
+
const channel = config.channels?.production || {};
|
|
816
|
+
const deployConfig = config.deploy?.production;
|
|
817
|
+
if (!deployConfig) {
|
|
818
|
+
throw new Error("missing deploy.production");
|
|
819
|
+
}
|
|
820
|
+
const checks = [];
|
|
821
|
+
const configuredProductionBindings = resolveSurfaceBindings({
|
|
822
|
+
config,
|
|
823
|
+
channelName: "production",
|
|
824
|
+
alias: "",
|
|
825
|
+
deployConfig,
|
|
826
|
+
});
|
|
827
|
+
const bindings = resolvedPlan.manifest?.surfaceBindings || resolveSurfaceBindings({
|
|
828
|
+
config,
|
|
829
|
+
channelName: "production",
|
|
830
|
+
alias: "",
|
|
831
|
+
deployConfig,
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
checks.push(preflightCheck({
|
|
835
|
+
name: "production-channel",
|
|
836
|
+
status: channel.canonical === true && channel.noindex === false ? "pass" : "fail",
|
|
837
|
+
details: {
|
|
838
|
+
canonical: channel.canonical,
|
|
839
|
+
noindex: channel.noindex,
|
|
840
|
+
},
|
|
841
|
+
message: channel.canonical === true && channel.noindex === false
|
|
842
|
+
? "production channel is canonical and indexable"
|
|
843
|
+
: "channels.production must be canonical=true and noindex=false",
|
|
844
|
+
}));
|
|
845
|
+
|
|
846
|
+
const configuredSurfaceNames = configuredProductionBindings.map((binding) => binding.surface).sort();
|
|
847
|
+
const plannedSurfaceNames = bindings.map((binding) => binding.surface).sort();
|
|
848
|
+
const missingSurfaces = configuredSurfaceNames.filter((surface) => !plannedSurfaceNames.includes(surface));
|
|
849
|
+
const extraSurfaces = plannedSurfaceNames.filter((surface) => !configuredSurfaceNames.includes(surface));
|
|
850
|
+
checks.push(preflightCheck({
|
|
851
|
+
name: "production-surface-set",
|
|
852
|
+
status: missingSurfaces.length === 0 && extraSurfaces.length === 0 ? "pass" : "fail",
|
|
853
|
+
details: {
|
|
854
|
+
configured: configuredSurfaceNames,
|
|
855
|
+
planned: plannedSurfaceNames,
|
|
856
|
+
missing: missingSurfaces,
|
|
857
|
+
extra: extraSurfaces,
|
|
858
|
+
},
|
|
859
|
+
message: missingSurfaces.length === 0 && extraSurfaces.length === 0
|
|
860
|
+
? "production plan covers every configured surface"
|
|
861
|
+
: `production plan surface mismatch: missing=${missingSurfaces.join(",") || "-"} extra=${extraSurfaces.join(",") || "-"}`,
|
|
862
|
+
}));
|
|
863
|
+
|
|
864
|
+
const concreteFailures = [];
|
|
865
|
+
for (const binding of bindings) {
|
|
866
|
+
const effectiveDeploy = surfaceDeployConfig(deployConfig, binding.surface);
|
|
867
|
+
try {
|
|
868
|
+
assertConcreteAwsDeployConfig({
|
|
869
|
+
deployConfig: effectiveDeploy,
|
|
870
|
+
channel: `production.surfaces.${binding.surface}`,
|
|
871
|
+
operation: "deploy",
|
|
872
|
+
});
|
|
873
|
+
} catch (error) {
|
|
874
|
+
concreteFailures.push(String(error.message || error));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
checks.push(preflightCheck({
|
|
878
|
+
name: "production-targets",
|
|
879
|
+
status: concreteFailures.length === 0 ? "pass" : "fail",
|
|
880
|
+
details: {
|
|
881
|
+
bindings: bindings.map((binding) => ({
|
|
882
|
+
surface: binding.surface,
|
|
883
|
+
bucket: binding.bucket,
|
|
884
|
+
distributionId: binding.distributionId,
|
|
885
|
+
objectPrefix: binding.objectPrefix,
|
|
886
|
+
manifestKey: binding.manifestKey,
|
|
887
|
+
})),
|
|
888
|
+
failures: concreteFailures,
|
|
889
|
+
},
|
|
890
|
+
message: concreteFailures.length === 0
|
|
891
|
+
? "production bucket and CloudFront distribution values are concrete"
|
|
892
|
+
: concreteFailures.join("; "),
|
|
893
|
+
}));
|
|
894
|
+
|
|
895
|
+
const hosts = bindings.map((binding) => ({
|
|
896
|
+
surface: binding.surface,
|
|
897
|
+
url: binding.url,
|
|
898
|
+
host: urlHost(binding.url),
|
|
899
|
+
distributionId: binding.distributionId,
|
|
900
|
+
}));
|
|
901
|
+
const invalidHosts = hosts.filter((entry) => !entry.host || !entry.url.startsWith("https://"));
|
|
902
|
+
checks.push(preflightCheck({
|
|
903
|
+
name: "production-surface-hosts",
|
|
904
|
+
status: invalidHosts.length === 0 ? "pass" : "fail",
|
|
905
|
+
details: { hosts, invalidHosts },
|
|
906
|
+
message: invalidHosts.length === 0
|
|
907
|
+
? "all production surfaces declare HTTPS hosts"
|
|
908
|
+
: `invalid production surface hosts: ${invalidHosts.map((entry) => entry.surface).join(", ")}`,
|
|
909
|
+
}));
|
|
910
|
+
|
|
911
|
+
const awsResults = [];
|
|
912
|
+
if (execute) {
|
|
913
|
+
const checkedBuckets = new Set();
|
|
914
|
+
for (const binding of bindings) {
|
|
915
|
+
const effectiveDeploy = surfaceDeployConfig(deployConfig, binding.surface);
|
|
916
|
+
const bucket = binding.bucket || effectiveDeploy.bucket || effectiveDeploy.target || "";
|
|
917
|
+
if (bucket && !checkedBuckets.has(bucket)) {
|
|
918
|
+
checkedBuckets.add(bucket);
|
|
919
|
+
const operation = {
|
|
920
|
+
action: "preflight-head-bucket",
|
|
921
|
+
surface: binding.surface,
|
|
922
|
+
command: "aws",
|
|
923
|
+
args: ["s3api", "head-bucket", "--bucket", bucket],
|
|
924
|
+
};
|
|
925
|
+
awsResults.push(runAdapterOperation({ operation, dryRun: false, commandRunner }));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const checkedDistributions = new Set();
|
|
930
|
+
for (const binding of bindings) {
|
|
931
|
+
const distributionId = binding.distributionId || "";
|
|
932
|
+
if (distributionId && !checkedDistributions.has(distributionId)) {
|
|
933
|
+
checkedDistributions.add(distributionId);
|
|
934
|
+
const operation = {
|
|
935
|
+
action: "preflight-get-distribution",
|
|
936
|
+
surface: binding.surface,
|
|
937
|
+
command: "aws",
|
|
938
|
+
args: ["cloudfront", "get-distribution", "--id", distributionId, "--output", "json"],
|
|
939
|
+
};
|
|
940
|
+
awsResults.push(runAdapterOperation({ operation, dryRun: false, commandRunner }));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const failedAws = awsResults.filter((result) => result.status === "failed");
|
|
946
|
+
checks.push(preflightCheck({
|
|
947
|
+
name: "production-aws-access",
|
|
948
|
+
status: !execute || failedAws.length === 0 ? "pass" : "fail",
|
|
949
|
+
details: {
|
|
950
|
+
execute,
|
|
951
|
+
operations: awsResults.map((result) => ({
|
|
952
|
+
action: result.action,
|
|
953
|
+
surface: result.surface,
|
|
954
|
+
status: result.status,
|
|
955
|
+
stderr: result.stderr,
|
|
956
|
+
})),
|
|
957
|
+
},
|
|
958
|
+
message: !execute
|
|
959
|
+
? "AWS access check planned but not executed"
|
|
960
|
+
: failedAws.length === 0
|
|
961
|
+
? "production role can inspect buckets and CloudFront distributions"
|
|
962
|
+
: `production AWS access failed: ${failedAws.map((result) => `${result.action}:${result.surface}`).join(", ")}`,
|
|
963
|
+
}));
|
|
964
|
+
|
|
965
|
+
const aliasChecks = [];
|
|
966
|
+
if (execute) {
|
|
967
|
+
const aliasesByDistribution = new Map();
|
|
968
|
+
for (const result of awsResults.filter((entry) => entry.action === "preflight-get-distribution" && entry.status !== "failed")) {
|
|
969
|
+
const aliases = parseCloudFrontAliases(result.stdout);
|
|
970
|
+
aliasesByDistribution.set(result.args[result.args.indexOf("--id") + 1], aliases);
|
|
971
|
+
}
|
|
972
|
+
for (const host of hosts) {
|
|
973
|
+
const aliases = aliasesByDistribution.get(host.distributionId) || [];
|
|
974
|
+
aliasChecks.push({
|
|
975
|
+
...host,
|
|
976
|
+
aliases,
|
|
977
|
+
matched: aliasMatchesHost(aliases, host.host),
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const missingAliases = aliasChecks.filter((entry) => !entry.matched);
|
|
982
|
+
checks.push(preflightCheck({
|
|
983
|
+
name: "production-cloudfront-aliases",
|
|
984
|
+
status: !execute || missingAliases.length === 0 ? "pass" : "fail",
|
|
985
|
+
details: {
|
|
986
|
+
execute,
|
|
987
|
+
aliases: aliasChecks,
|
|
988
|
+
},
|
|
989
|
+
message: !execute
|
|
990
|
+
? "CloudFront alias check planned but not executed"
|
|
991
|
+
: missingAliases.length === 0
|
|
992
|
+
? "production CloudFront aliases cover every surface host"
|
|
993
|
+
: `missing CloudFront aliases: ${missingAliases.map((entry) => entry.host).join(", ")}`,
|
|
994
|
+
}));
|
|
995
|
+
|
|
996
|
+
const dnsChecks = [];
|
|
997
|
+
if (execute) {
|
|
998
|
+
for (const host of hosts) {
|
|
999
|
+
try {
|
|
1000
|
+
const answers = await dnsResolver(host.host);
|
|
1001
|
+
dnsChecks.push({ ...host, status: "pass", answers });
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
dnsChecks.push({ ...host, status: "fail", error: String(error.message || error) });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const failedDns = dnsChecks.filter((entry) => entry.status === "fail");
|
|
1008
|
+
checks.push(preflightCheck({
|
|
1009
|
+
name: "production-dns",
|
|
1010
|
+
status: !execute || failedDns.length === 0 ? "pass" : "fail",
|
|
1011
|
+
details: {
|
|
1012
|
+
execute,
|
|
1013
|
+
hosts: dnsChecks,
|
|
1014
|
+
},
|
|
1015
|
+
message: !execute
|
|
1016
|
+
? "DNS check planned but not executed"
|
|
1017
|
+
: failedDns.length === 0
|
|
1018
|
+
? "production DNS resolves for every surface host"
|
|
1019
|
+
: `production DNS failed: ${failedDns.map((entry) => entry.host).join(", ")}`,
|
|
1020
|
+
}));
|
|
1021
|
+
|
|
1022
|
+
return {
|
|
1023
|
+
schemaVersion: 1,
|
|
1024
|
+
contract: "kungfu-buildchain-web-surface-production-preflight",
|
|
1025
|
+
checkedAt,
|
|
1026
|
+
execute,
|
|
1027
|
+
channel: "production",
|
|
1028
|
+
status: preflightStatus(checks),
|
|
1029
|
+
url: resolvedPlan.url,
|
|
1030
|
+
urls: resolvedPlan.urls,
|
|
1031
|
+
sourceSha: resolvedPlan.manifest.sourceSha,
|
|
1032
|
+
artifactHash: resolvedPlan.artifact.hash,
|
|
1033
|
+
surfaceBindings: bindings,
|
|
1034
|
+
checks,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function healthStatus(checks) {
|
|
1039
|
+
return checks.some((check) => check.status === "fail") ? "failed" : "passed";
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function noindexHeader(headers) {
|
|
1043
|
+
const value = headers?.get?.("x-robots-tag") || "";
|
|
1044
|
+
return String(value).toLowerCase().includes("noindex");
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function noindexMeta(html = "") {
|
|
1048
|
+
const source = String(html || "").toLowerCase();
|
|
1049
|
+
return /<meta\s+[^>]*name=["']robots["'][^>]*content=["'][^"']*noindex/.test(source) ||
|
|
1050
|
+
/<meta\s+[^>]*content=["'][^"']*noindex[^"']*["'][^>]*name=["']robots["']/.test(source);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function htmlLikeResponse(response, url = "") {
|
|
1054
|
+
const contentType = response.headers?.get?.("content-type") || "";
|
|
1055
|
+
return String(contentType).toLowerCase().includes("text/html") || /\/$|\.html(?:$|[?#])/.test(String(url || ""));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export async function checkWebSurfaceHealth({
|
|
1059
|
+
result = null,
|
|
1060
|
+
plan = null,
|
|
1061
|
+
cwd = process.cwd(),
|
|
1062
|
+
fetchImpl = fetch,
|
|
1063
|
+
checkedAt = new Date().toISOString(),
|
|
1064
|
+
allowedStatuses = [200],
|
|
1065
|
+
} = {}) {
|
|
1066
|
+
const loadedConfig = loadBuildchainConfig(cwd);
|
|
1067
|
+
const config = assertWebSurfaceConfig(loadedConfig);
|
|
1068
|
+
const manifest = result?.manifest || plan?.manifest;
|
|
1069
|
+
const channel = result?.channel || plan?.channel || manifest?.channel || "production";
|
|
1070
|
+
const urls = result?.urls || plan?.urls || (manifest?.surfaceBindings
|
|
1071
|
+
? Object.fromEntries(manifest.surfaceBindings.map((binding) => [binding.surface, binding.url]))
|
|
1072
|
+
: {});
|
|
1073
|
+
const bindings = manifest?.surfaceBindings || [];
|
|
1074
|
+
const checks = [];
|
|
1075
|
+
if (Object.keys(urls).length === 0) {
|
|
1076
|
+
checks.push({
|
|
1077
|
+
surface: "__urls__",
|
|
1078
|
+
url: "",
|
|
1079
|
+
status: "fail",
|
|
1080
|
+
httpStatus: 0,
|
|
1081
|
+
finalUrl: "",
|
|
1082
|
+
noindexHeader: false,
|
|
1083
|
+
message: "web-surface health check requires at least one surface URL from an apply result or deploy plan",
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
for (const [surface, url] of Object.entries(urls)) {
|
|
1088
|
+
try {
|
|
1089
|
+
const response = await fetchImpl(url, { redirect: "follow" });
|
|
1090
|
+
const expectedNoindex = Boolean(config.channels?.[channel]?.noindex);
|
|
1091
|
+
const headerNoindex = noindexHeader(response.headers);
|
|
1092
|
+
const body = htmlLikeResponse(response, url) && typeof response.text === "function"
|
|
1093
|
+
? await response.text()
|
|
1094
|
+
: "";
|
|
1095
|
+
const metaNoindex = noindexMeta(body);
|
|
1096
|
+
const noindex = headerNoindex || metaNoindex;
|
|
1097
|
+
const statusOk = allowedStatuses.includes(response.status);
|
|
1098
|
+
const noindexOk = channel !== "production" || expectedNoindex || !noindex;
|
|
1099
|
+
checks.push({
|
|
1100
|
+
surface,
|
|
1101
|
+
url,
|
|
1102
|
+
status: statusOk && noindexOk ? "pass" : "fail",
|
|
1103
|
+
httpStatus: response.status,
|
|
1104
|
+
finalUrl: response.url || url,
|
|
1105
|
+
noindexHeader: noindex,
|
|
1106
|
+
noindexHeaderValue: headerNoindex,
|
|
1107
|
+
noindexMeta: metaNoindex,
|
|
1108
|
+
message: statusOk && noindexOk
|
|
1109
|
+
? "surface is reachable"
|
|
1110
|
+
: `surface health failed: http=${response.status}, noindex=${noindex}`,
|
|
1111
|
+
});
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
checks.push({
|
|
1114
|
+
surface,
|
|
1115
|
+
url,
|
|
1116
|
+
status: "fail",
|
|
1117
|
+
httpStatus: 0,
|
|
1118
|
+
finalUrl: "",
|
|
1119
|
+
noindexHeader: false,
|
|
1120
|
+
message: String(error.message || error),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const manifestChecks = bindings.map((binding) => ({
|
|
1126
|
+
surface: binding.surface,
|
|
1127
|
+
manifestKey: binding.manifestKey,
|
|
1128
|
+
bucket: binding.bucket,
|
|
1129
|
+
distributionId: binding.distributionId,
|
|
1130
|
+
status: binding.manifestKey ? "pass" : "fail",
|
|
1131
|
+
}));
|
|
1132
|
+
checks.push({
|
|
1133
|
+
surface: "__manifest__",
|
|
1134
|
+
url: "",
|
|
1135
|
+
status: manifestChecks.length > 0 && manifestChecks.every((entry) => entry.status === "pass") ? "pass" : "fail",
|
|
1136
|
+
manifests: manifestChecks,
|
|
1137
|
+
message: "deployment manifest pointers are recorded for every surface",
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
return {
|
|
1141
|
+
schemaVersion: 1,
|
|
1142
|
+
contract: "kungfu-buildchain-web-surface-health-check",
|
|
1143
|
+
checkedAt,
|
|
1144
|
+
channel,
|
|
1145
|
+
status: healthStatus(checks),
|
|
1146
|
+
sourceSha: manifest?.sourceSha || result?.sourceSha || "",
|
|
1147
|
+
artifactHash: manifest?.artifactHash || result?.artifactHash || "",
|
|
1148
|
+
urls,
|
|
1149
|
+
checks,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
725
1153
|
function deployBindingOperations({ artifactRoot, deployConfig, manifest, binding }) {
|
|
726
1154
|
const effectiveDeploy = surfaceDeployConfig(deployConfig, binding.surface);
|
|
727
1155
|
const bucket = binding.bucket || effectiveDeploy.bucket || effectiveDeploy.target || "";
|
|
@@ -757,7 +1185,7 @@ function deployBindingOperations({ artifactRoot, deployConfig, manifest, binding
|
|
|
757
1185
|
"--distribution-id",
|
|
758
1186
|
distribution,
|
|
759
1187
|
"--paths",
|
|
760
|
-
|
|
1188
|
+
viewerWildcardPath(binding),
|
|
761
1189
|
cdnPath(binding.manifestKey),
|
|
762
1190
|
],
|
|
763
1191
|
});
|
|
@@ -994,7 +1422,7 @@ function cleanupEntryOperations({ deployConfig, entry, bucket }) {
|
|
|
994
1422
|
"--distribution-id",
|
|
995
1423
|
distribution,
|
|
996
1424
|
"--paths",
|
|
997
|
-
|
|
1425
|
+
viewerWildcardPath(binding),
|
|
998
1426
|
cdnPath(binding.manifestKey),
|
|
999
1427
|
],
|
|
1000
1428
|
});
|
|
@@ -169,6 +169,8 @@ export function createWebSurfaceReleasePassport({
|
|
|
169
169
|
triggeringActor = "",
|
|
170
170
|
runnerActor = "",
|
|
171
171
|
oidcDeployIdentity = "",
|
|
172
|
+
productionPreflight = undefined,
|
|
173
|
+
healthCheck = undefined,
|
|
172
174
|
} = {}) {
|
|
173
175
|
const normalizedStatus = inferStatus({ result, applyOutcome, jobStatus }) || status;
|
|
174
176
|
const decisionType = channel === "production" ? "release-pr-merge-or-manual-approval" : "main-merge-staging";
|
|
@@ -222,6 +224,8 @@ export function createWebSurfaceReleasePassport({
|
|
|
222
224
|
evidence: {
|
|
223
225
|
applyResultBound: Boolean(result),
|
|
224
226
|
failureContext: normalizedStatus === "success" ? "" : optionalString(applyOutcome || jobStatus || "missing-apply-result"),
|
|
227
|
+
productionPreflight,
|
|
228
|
+
healthCheck,
|
|
225
229
|
},
|
|
226
230
|
};
|
|
227
231
|
}
|
|
@@ -240,6 +244,12 @@ export function renderWebSurfaceReleaseFeedbackComment({
|
|
|
240
244
|
const title = channel === "production" ? "Buildchain production deploy" : "Buildchain staging deploy";
|
|
241
245
|
const failure = passport.evidence?.failureContext ? [`- Failure context: \`${passport.evidence.failureContext}\``] : [];
|
|
242
246
|
const targetLine = target.pullNumber ? `- Comment target: PR #${target.pullNumber}` : "";
|
|
247
|
+
const preflight = passport.evidence?.productionPreflight?.status
|
|
248
|
+
? `- Production preflight: \`${passport.evidence.productionPreflight.status}\``
|
|
249
|
+
: "";
|
|
250
|
+
const health = passport.evidence?.healthCheck?.status
|
|
251
|
+
? `- Health check: \`${passport.evidence.healthCheck.status}\``
|
|
252
|
+
: "";
|
|
243
253
|
return [
|
|
244
254
|
marker,
|
|
245
255
|
`## ${title}`,
|
|
@@ -250,6 +260,8 @@ export function renderWebSurfaceReleaseFeedbackComment({
|
|
|
250
260
|
`- Artifact: \`${release.artifactHash || ""}\``,
|
|
251
261
|
`- Target: \`${release.target || ""}\``,
|
|
252
262
|
`- Rollback pointer: \`${release.rollbackPointer || ""}\``,
|
|
263
|
+
preflight,
|
|
264
|
+
health,
|
|
253
265
|
`- Run: ${passport.workflow?.url || ""}`,
|
|
254
266
|
`- Passport artifact: \`${passportArtifact || "buildchain-web-surface-release-passport"}\``,
|
|
255
267
|
targetLine,
|
|
@@ -263,6 +275,8 @@ export async function webSurfaceReleaseFeedbackCli(env = process.env, { fetchImp
|
|
|
263
275
|
const payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
|
|
264
276
|
const channel = optionalString(env.BUILDCHAIN_WEB_SURFACE_CHANNEL || "staging");
|
|
265
277
|
const result = readJsonIfExists(env.APPLY_RESULT_PATH || "");
|
|
278
|
+
const productionPreflight = readJsonIfExists(env.PREFLIGHT_RESULT_PATH || "");
|
|
279
|
+
const healthCheck = readJsonIfExists(env.HEALTH_RESULT_PATH || "");
|
|
266
280
|
const repository = optionalString(env.GITHUB_REPOSITORY);
|
|
267
281
|
const sourceSha = optionalString(result?.sourceSha || env.GITHUB_SHA);
|
|
268
282
|
const workflowRunUrl = runUrl({
|
|
@@ -305,6 +319,8 @@ export async function webSurfaceReleaseFeedbackCli(env = process.env, { fetchImp
|
|
|
305
319
|
triggeringActor: env.GITHUB_TRIGGERING_ACTOR,
|
|
306
320
|
runnerActor: env.RUNNER_NAME || env.GITHUB_ACTOR,
|
|
307
321
|
oidcDeployIdentity: env.DEPLOY_IDENTITY_REF || env.AWS_ROLE_ARN,
|
|
322
|
+
productionPreflight,
|
|
323
|
+
healthCheck,
|
|
308
324
|
});
|
|
309
325
|
const outputPath = env.OUTPUT_PATH || `.buildchain/web-surface-${channel}-release-passport.json`;
|
|
310
326
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
package/scripts/web-surface.mjs
CHANGED
|
@@ -5,9 +5,11 @@ import { pathToFileURL } from "node:url";
|
|
|
5
5
|
import {
|
|
6
6
|
applyWebSurfaceCleanup,
|
|
7
7
|
applyWebSurfaceDeploy,
|
|
8
|
+
checkWebSurfaceHealth,
|
|
8
9
|
defaultWebSurfaceAlias,
|
|
9
10
|
planWebSurfaceCleanup,
|
|
10
11
|
planWebSurfaceDeploy,
|
|
12
|
+
preflightWebSurfaceProduction,
|
|
11
13
|
validateWebSurfaceProject,
|
|
12
14
|
} from "./web-surface-core.mjs";
|
|
13
15
|
import { writeGitHubOutputs } from "./build-contract-core.mjs";
|
|
@@ -138,6 +140,50 @@ export function webSurfaceCli() {
|
|
|
138
140
|
return result;
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
if (mode === "production-preflight") {
|
|
144
|
+
const plan = readJsonFileArg("plan");
|
|
145
|
+
const result = preflightWebSurfaceProduction({
|
|
146
|
+
cwd,
|
|
147
|
+
plan,
|
|
148
|
+
execute: readBooleanArg("execute", false),
|
|
149
|
+
});
|
|
150
|
+
return Promise.resolve(result).then((resolved) => {
|
|
151
|
+
writeJson(resolved, output);
|
|
152
|
+
writeGitHubOutputs({
|
|
153
|
+
"web-surface-production-preflight-status": resolved.status,
|
|
154
|
+
"web-surface-production-preflight-json": JSON.stringify(resolved),
|
|
155
|
+
});
|
|
156
|
+
if (resolved.status !== "passed") {
|
|
157
|
+
throw new Error(`web-surface production preflight failed: ${resolved.checks
|
|
158
|
+
.filter((check) => check.status === "fail")
|
|
159
|
+
.map((check) => check.message || check.name)
|
|
160
|
+
.join("; ")}`);
|
|
161
|
+
}
|
|
162
|
+
return resolved;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (mode === "health-check") {
|
|
167
|
+
const resultFile = readArg("result", process.env.BUILDCHAIN_WEB_SURFACE_APPLY_RESULT || "");
|
|
168
|
+
const result = resultFile ? JSON.parse(fs.readFileSync(path.resolve(resultFile), "utf8")) : null;
|
|
169
|
+
const plan = readJsonFileArg("plan");
|
|
170
|
+
const health = checkWebSurfaceHealth({ cwd, result, plan });
|
|
171
|
+
return Promise.resolve(health).then((resolved) => {
|
|
172
|
+
writeJson(resolved, output);
|
|
173
|
+
writeGitHubOutputs({
|
|
174
|
+
"web-surface-health-status": resolved.status,
|
|
175
|
+
"web-surface-health-json": JSON.stringify(resolved),
|
|
176
|
+
});
|
|
177
|
+
if (resolved.status !== "passed") {
|
|
178
|
+
throw new Error(`web-surface health check failed: ${resolved.checks
|
|
179
|
+
.filter((check) => check.status === "fail")
|
|
180
|
+
.map((check) => check.message || check.surface)
|
|
181
|
+
.join("; ")}`);
|
|
182
|
+
}
|
|
183
|
+
return resolved;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
141
187
|
if (mode === "cleanup-plan") {
|
|
142
188
|
const aliases = readArg("aliases", process.env.BUILDCHAIN_WEB_SURFACE_ALIASES || "")
|
|
143
189
|
.split(",")
|
|
@@ -198,7 +244,7 @@ export function webSurfaceCli() {
|
|
|
198
244
|
|
|
199
245
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
200
246
|
try {
|
|
201
|
-
webSurfaceCli();
|
|
247
|
+
await Promise.resolve(webSurfaceCli());
|
|
202
248
|
} catch (error) {
|
|
203
249
|
console.error(`::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`);
|
|
204
250
|
process.exitCode = 1;
|