@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.
@@ -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.2",
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:22017d4cbe0d89aecfcada7b93f5cdf2935837b2236ae4aeff23f19dbf3d26cb"
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:22017d4cbe0d89aecfcada7b93f5cdf2935837b2236ae4aeff23f19dbf3d26cb"
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:478e656104272675d3be3391158251472e0c2515b3113376ef0e52757b47ace0"
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:7fbde28193df754afda019e218defb5d08aa2c64298382d277927faba5468240"
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:f3ba17b4a48e7d0f2996fb9f39d5ff7f16f1e2de1958fc07718b04bdf8af5533"
345
+ "auditDigest": "sha256:c3745538a9b17829bf07db485247f8e86245736798b9e631a935e53073a382d0"
346
346
  }
347
347
  ],
348
348
  "compatibilityDigest": "sha256:36421b592a782decbfe31a7d8d42d70ce1100a56a2c4f4a0ccc21f0617dc72bf",
349
- "contractDigest": "sha256:12d39a2256bc4b93c81aa3ba1ce62ccc35fbb5054fca395815d77d8902aa42fe"
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:614aea65d745e8978d8329545753adf13b8914f84fe35b0148abbd1aff0c0dc2"
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:614aea65d745e8978d8329545753adf13b8914f84fe35b0148abbd1aff0c0dc2"
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:22017d4cbe0d89aecfcada7b93f5cdf2935837b2236ae4aeff23f19dbf3d26cb"
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:614aea65d745e8978d8329545753adf13b8914f84fe35b0148abbd1aff0c0dc2"
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, a developer substrate site may have a hub plus
68
- separate hostnames for core and Buildchain documentation. These are not just
69
- navigation paths; staging and preview should be able to verify host-level
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 three
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.2",
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 = normalizeImpactLedger(impactMeta.value, { tag: resolvedTag, line, decision: "unknown" });
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) => [cdnWildcardPath(binding.objectPrefix), cdnPath(binding.manifestKey)]);
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
- cdnWildcardPath(binding.objectPrefix),
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
- `${cdnPath(binding.objectPrefix)}/*`,
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 });
@@ -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;