@kungfu-tech/buildchain 2.2.6-alpha.0 → 2.3.0-alpha.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.
@@ -307,16 +307,16 @@ schema = 1
307
307
 
308
308
  [project]
309
309
  type = "web-surface"
310
- name = "site-kungfu-tech"
311
- site = "kungfu-tech"
310
+ name = "site-libkungfu-dev"
311
+ site = "libkungfu-dev"
312
312
 
313
313
  [channels.preview]
314
- url_pattern = "https://{alias}.preview.kungfu.tech"
314
+ url_pattern = "https://{alias}.preview.libkungfu.dev"
315
315
  visibility = "ephemeral"
316
316
  noindex = true
317
317
 
318
318
  [channels.staging]
319
- url = "https://staging.kungfu.tech"
319
+ url = "https://staging.libkungfu.dev"
320
320
  visibility = "protected"
321
321
  access_control = "managed-network"
322
322
  edge_auth = "none"
@@ -324,20 +324,33 @@ noindex = true
324
324
  promotable = true
325
325
 
326
326
  [channels.production]
327
- url = "https://kungfu.tech"
327
+ url = "https://libkungfu.dev"
328
328
  visibility = "public"
329
329
  canonical = true
330
330
  noindex = false
331
331
 
332
+ [surfaces.hub]
333
+ path = "/"
334
+ production_url = "https://libkungfu.dev"
335
+ staging_url = "https://staging.libkungfu.dev"
336
+ preview_url_pattern = "https://{alias}.preview.libkungfu.dev"
337
+
338
+ [surfaces.core]
339
+ path = "/core/"
340
+ production_url = "https://core.libkungfu.dev"
341
+ staging_url = "https://core.staging.libkungfu.dev"
342
+ preview_url_pattern = "https://core-{alias}.preview.libkungfu.dev"
343
+
332
344
  [deploy.production]
333
345
  adapter = "aws-s3-cloudfront"
334
- bucket = "kungfu-tech-production"
346
+ bucket = "libkungfu-dev-production"
335
347
  artifact_path = "dist"
336
348
  secret_refs = ["AWS_ROLE_ARN"]
337
349
  ```
338
350
 
339
- See [Web-surface deployments](web-surface-deployments.md) for the manifest,
340
- preview alias, retention, cleanup, and dry-run deploy contract.
351
+ See [Web-surface deployments](web-surface-deployments.md) for named surface host
352
+ mappings, the manifest, preview alias, retention, cleanup, and dry-run deploy
353
+ contract.
341
354
 
342
355
  ## Examples
343
356
 
@@ -42,6 +42,7 @@ expected ref flow requires a major bump.
42
42
  | --- | --- | --- | --- |
43
43
  | 2026-07-02 | Buildchain toolkit observability is a minor surface. | `v2.1` | It adds the public logging SDK, CLI observability commands, and package subpaths that consumers can import. |
44
44
  | 2026-07-02 | Release passport and binary distribution are a minor surface. | `v2.2` | They add agent-readable release passport files, artifact evidence, impact ledger, agent index, GitHub Release collection and verification commands, and standalone binary assets. |
45
+ | 2026-07-02 | Web surface host mapping is a minor surface. | `v2.3` | It adds first-class multi-host surface bindings, reusable workflow URL outputs, per-surface deployment overrides, and an agent-readable fixture contract. |
45
46
 
46
47
  ## Runner Policy
47
48
 
@@ -61,6 +61,72 @@ artifact_path = "dist"
61
61
  secret_refs = ["AWS_ROLE_ARN"]
62
62
  ```
63
63
 
64
+ ### Multi-Surface Host Mapping
65
+
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.
71
+
72
+ Declare named surfaces with per-channel URLs:
73
+
74
+ ```toml
75
+ [surfaces.hub]
76
+ path = "/"
77
+ production_url = "https://libkungfu.dev"
78
+ staging_url = "https://staging.libkungfu.dev"
79
+ preview_url_pattern = "https://{alias}.preview.libkungfu.dev"
80
+
81
+ [surfaces.core]
82
+ path = "/core/"
83
+ production_url = "https://core.libkungfu.dev"
84
+ staging_url = "https://core.staging.libkungfu.dev"
85
+ preview_url_pattern = "https://core-{alias}.preview.libkungfu.dev"
86
+
87
+ [surfaces.buildchain]
88
+ path = "/buildchain/"
89
+ production_url = "https://buildchain.libkungfu.dev"
90
+ staging_url = "https://buildchain.staging.libkungfu.dev"
91
+ preview_url_pattern = "https://buildchain-{alias}.preview.libkungfu.dev"
92
+ ```
93
+
94
+ Buildchain resolves every `(channel, surface)` pair. A preview alias such as
95
+ `pr-12` becomes:
96
+
97
+ ```text
98
+ hub: https://pr-12.preview.libkungfu.dev
99
+ core: https://core-pr-12.preview.libkungfu.dev
100
+ buildchain: https://buildchain-pr-12.preview.libkungfu.dev
101
+ ```
102
+
103
+ When `surfaces` is omitted, Buildchain preserves the legacy single-surface
104
+ contract by creating an implicit `default` surface from the channel URL. When a
105
+ surface is intentionally path-only, declare it explicitly:
106
+
107
+ ```toml
108
+ [surfaces.docs]
109
+ path = "/docs/"
110
+ path_only = true
111
+ ```
112
+
113
+ `path_only = true` is an exception, not the default. Without it, every named
114
+ surface must declare `preview_url_pattern`, `staging_url`, and
115
+ `production_url`. This makes staging/production mismatches fail during
116
+ validation instead of becoming invisible deploy drift.
117
+
118
+ Adapter strategy remains explicit. The default `aws-s3-cloudfront` plan uses the
119
+ channel deploy target for every surface, and each binding records its own
120
+ bucket, distribution id, object prefix, manifest key, source path, and URL. A
121
+ channel can override target details per surface:
122
+
123
+ ```toml
124
+ [deploy.staging.surfaces.core]
125
+ bucket = "libkungfu-dev-core-staging"
126
+ cloudfront_distribution = "E-CORE-STAGING"
127
+ origin_path = "/core"
128
+ ```
129
+
64
130
  Buildchain validates these hard constraints:
65
131
 
66
132
  - `channels.preview.url_pattern` is required and must contain the alias shape
@@ -73,6 +139,8 @@ Buildchain validates these hard constraints:
73
139
  - `channels.staging.noindex = true` is required.
74
140
  - `channels.production.url` is required.
75
141
  - deploy adapters must be declared per channel.
142
+ - named surfaces must declare first-class URLs for every channel unless
143
+ `path_only = true` is explicitly set.
76
144
  - secret material must be declared as reference names, such as
77
145
  `secret_refs = ["AWS_ROLE_ARN"]`; inline secret-like deploy keys are rejected.
78
146
 
@@ -116,13 +184,13 @@ rollback:
116
184
  {
117
185
  "schemaVersion": 1,
118
186
  "contract": "kungfu-buildchain-web-surface-deployment",
119
- "site": "kungfu-tech",
187
+ "site": "libkungfu-dev",
120
188
  "channel": "preview",
121
189
  "alias": "sha-abcdef123456",
122
- "url": "https://sha-abcdef123456.preview.kungfu.tech",
190
+ "url": "https://sha-abcdef123456.preview.libkungfu.dev",
123
191
  "sourceSha": "...",
124
192
  "artifactHash": "...",
125
- "deployTarget": "kungfu-tech-preview",
193
+ "deployTarget": "libkungfu-dev-preview",
126
194
  "adapter": "aws-s3-cloudfront",
127
195
  "deployedAt": "2026-07-01T00:00:00.000Z",
128
196
  "retentionClass": "preview-sha-immutable",
@@ -130,7 +198,24 @@ rollback:
130
198
  "accessControl": "none",
131
199
  "edgeAuth": "none",
132
200
  "noindex": true,
133
- "secretRefs": ["AWS_ROLE_ARN"]
201
+ "secretRefs": ["AWS_ROLE_ARN"],
202
+ "surfaceBindings": [
203
+ {
204
+ "surface": "hub",
205
+ "channel": "preview",
206
+ "alias": "sha-abcdef123456",
207
+ "url": "https://sha-abcdef123456.preview.libkungfu.dev",
208
+ "sourcePath": "/",
209
+ "canonicalUrl": "https://libkungfu.dev",
210
+ "bucket": "libkungfu-dev-preview",
211
+ "distributionId": "E-PREVIEW",
212
+ "originPath": "",
213
+ "objectPrefix": "sha-abcdef123456",
214
+ "manifestKey": ".buildchain/deployments/sha-abcdef123456/hub.json",
215
+ "noindex": true,
216
+ "accessControl": "none"
217
+ }
218
+ ]
134
219
  }
135
220
  ```
136
221
 
@@ -169,6 +254,7 @@ The CLI emits GitHub outputs when `GITHUB_OUTPUT` is present:
169
254
  - `web-surface-channel`
170
255
  - `web-surface-alias`
171
256
  - `web-surface-url`
257
+ - `web-surface-urls-json`
172
258
  - `web-surface-artifact-hash`
173
259
  - `web-surface-manifest-json`
174
260
 
@@ -231,13 +317,13 @@ node scripts/web-surface.mjs \
231
317
  ```
232
318
 
233
319
  Apply output records the channel, alias, source SHA, artifact hash, target
234
- bucket, object prefix, manifest key, CDN invalidation paths, actor/run metadata,
235
- and every adapter operation with `executed`, `exitCode`, `stdout`, and `stderr`.
236
- If an operation fails, Buildchain records the failed operation, stops subsequent
237
- adapter operations, and exits non-zero after writing the result JSON. Buildchain
238
- records secret reference names only; the runner must provide the AWS CLI and
239
- credentials outside Buildchain, typically through OIDC and the declared
240
- `secret_refs`.
320
+ bucket, object prefix, manifest key, all surface URLs, all surface bindings, CDN
321
+ invalidation paths, actor/run metadata, and every adapter operation with
322
+ `executed`, `exitCode`, `stdout`, and `stderr`. If an operation fails,
323
+ Buildchain records the failed operation, stops subsequent adapter operations,
324
+ and exits non-zero after writing the result JSON. Buildchain records secret
325
+ reference names only; the runner must provide the AWS CLI and credentials
326
+ outside Buildchain, typically through OIDC and the declared `secret_refs`.
241
327
 
242
328
  ## Cleanup Plans
243
329
 
@@ -4,6 +4,20 @@ This fixture models a static site repository that wants Buildchain deployment
4
4
  semantics without opting into package release lines.
5
5
 
6
6
  It proves that `project.type = "web-surface"` can declare preview, staging, and
7
- production channels, generate deterministic deployment manifests, and create
8
- dry-run deploy and cleanup plans without touching AWS.
7
+ production channels, named surface host mappings, deterministic deployment
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
11
+ first-class surfaces:
12
+
13
+ ```text
14
+ hub https://libkungfu.dev
15
+ core https://core.libkungfu.dev
16
+ buildchain https://buildchain.libkungfu.dev
17
+ ```
18
+
19
+ For preview and staging, Buildchain resolves the same surface names to the
20
+ channel-specific hosts and records them in `surfaceBindings`. Path-only pages
21
+ can still be declared with `path_only = true`, but this fixture intentionally
22
+ uses first-class hosts for every surface so validation can catch missing
23
+ channel mappings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.2.6-alpha.0",
3
+ "version": "2.3.0-alpha.0",
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",
@@ -116,6 +116,9 @@ export function normalizeBuildchainConfig(config) {
116
116
  if (normalized.deploy !== undefined) {
117
117
  normalized.deploy = normalizeDeploySection(normalized.deploy, normalized.project);
118
118
  }
119
+ if (normalized.surfaces !== undefined) {
120
+ normalized.surfaces = normalizeSurfacesSection(normalized.surfaces);
121
+ }
119
122
  if (normalized.retention !== undefined) {
120
123
  normalized.retention = normalizeRetentionSection(normalized.retention);
121
124
  }
@@ -291,20 +294,113 @@ function normalizeDeployConfig(name, config) {
291
294
  ? undefined
292
295
  : posixPath(assertString(config.artifact_path, `deploy.${name}.artifact_path`)),
293
296
  secretRefs: normalizeStringArray(config.secret_refs, `deploy.${name}.secret_refs`),
297
+ surfaces: config.surfaces === undefined
298
+ ? undefined
299
+ : normalizeDeploySurfaceOverrides(name, config.surfaces),
300
+ };
301
+ delete normalized.artifact_path;
302
+ delete normalized.secret_refs;
303
+ if (normalized.surfaces === undefined) {
304
+ delete normalized.surfaces;
305
+ }
306
+ assertNoInlineSecretValues(config, `deploy.${name}`, new Set(["secret_refs", "surfaces"]));
307
+ return normalized;
308
+ }
309
+
310
+ function normalizeDeploySurfaceOverrides(channelName, surfaces) {
311
+ assertPlainObject(surfaces, `deploy.${channelName}.surfaces`);
312
+ return Object.fromEntries(
313
+ Object.entries(surfaces).map(([surfaceName, override]) => [
314
+ surfaceName,
315
+ normalizeDeploySurfaceOverride(channelName, surfaceName, override),
316
+ ]),
317
+ );
318
+ }
319
+
320
+ function normalizeDeploySurfaceOverride(channelName, surfaceName, override) {
321
+ assertPlainObject(override, `deploy.${channelName}.surfaces.${surfaceName}`);
322
+ const label = `deploy.${channelName}.surfaces.${surfaceName}`;
323
+ const normalized = {
324
+ ...override,
325
+ artifactPath: override.artifact_path === undefined
326
+ ? undefined
327
+ : posixPath(assertString(override.artifact_path, `${label}.artifact_path`)),
328
+ originPath: override.origin_path === undefined
329
+ ? undefined
330
+ : assertString(override.origin_path, `${label}.origin_path`),
331
+ secretRefs: normalizeStringArray(override.secret_refs, `${label}.secret_refs`),
294
332
  };
295
333
  delete normalized.artifact_path;
334
+ delete normalized.origin_path;
296
335
  delete normalized.secret_refs;
336
+ if (normalized.artifactPath === undefined) {
337
+ delete normalized.artifactPath;
338
+ }
339
+ if (normalized.originPath === undefined) {
340
+ delete normalized.originPath;
341
+ }
342
+ if (normalized.secretRefs.length === 0) {
343
+ delete normalized.secretRefs;
344
+ }
345
+ assertNoInlineSecretValues(override, label, new Set(["secret_refs"]));
346
+ return normalized;
347
+ }
348
+
349
+ function assertNoInlineSecretValues(config, label, ignoredKeys = new Set()) {
297
350
  for (const [key, value] of Object.entries(config)) {
298
- if (key === "secret_refs" || key.endsWith("_ref") || key.endsWith("_refs")) {
351
+ if (ignoredKeys.has(key) || key.endsWith("_ref") || key.endsWith("_refs")) {
299
352
  continue;
300
353
  }
301
354
  if (/(secret|token|password)/i.test(key) && value !== undefined) {
302
- throw new Error(`deploy.${name}.${key} must be declared as a secret reference, not a secret value`);
355
+ throw new Error(`${label}.${key} must be declared as a secret reference, not a secret value`);
303
356
  }
304
357
  }
358
+ }
359
+
360
+ function normalizeSurfacesSection(surfaces) {
361
+ assertPlainObject(surfaces, "surfaces");
362
+ const entries = Object.entries(surfaces);
363
+ if (entries.length === 0) {
364
+ throw new Error("surfaces must declare at least one surface");
365
+ }
366
+ return Object.fromEntries(
367
+ entries.map(([name, surface]) => [
368
+ name,
369
+ normalizeSurfaceConfig(name, surface),
370
+ ]),
371
+ );
372
+ }
373
+
374
+ function normalizeSurfaceConfig(name, surface) {
375
+ assertPlainObject(surface, `surfaces.${name}`);
376
+ const normalized = {
377
+ name,
378
+ path: surface.path === undefined
379
+ ? "/"
380
+ : normalizeSurfacePath(assertString(surface.path, `surfaces.${name}.path`)),
381
+ pathOnly: optionalBoolean(surface.path_only, false),
382
+ canonical: optionalBoolean(surface.canonical, name === "default" || name === "hub"),
383
+ };
384
+ if (surface.preview_url_pattern !== undefined) {
385
+ normalized.previewUrlPattern = assertString(surface.preview_url_pattern, `surfaces.${name}.preview_url_pattern`);
386
+ }
387
+ if (surface.staging_url !== undefined) {
388
+ normalized.stagingUrl = assertString(surface.staging_url, `surfaces.${name}.staging_url`);
389
+ }
390
+ if (surface.production_url !== undefined) {
391
+ normalized.productionUrl = assertString(surface.production_url, `surfaces.${name}.production_url`);
392
+ }
305
393
  return normalized;
306
394
  }
307
395
 
396
+ function normalizeSurfacePath(value) {
397
+ const normalized = `/${String(value).replace(/^\/+/, "")}`.replace(/\/{2,}/g, "/");
398
+ if (normalized === "/") {
399
+ return normalized;
400
+ }
401
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
402
+ }
403
+
308
404
  function normalizeRetentionSection(retention) {
309
405
  assertPlainObject(retention, "retention");
310
406
  return Object.fromEntries(
@@ -390,6 +486,25 @@ function validateWebSurfaceConfig(config) {
390
486
  throw new Error("security.staging.isolated_providers must not be false for web-surface");
391
487
  }
392
488
  }
489
+ validateWebSurfaceSurfaces(config);
490
+ }
491
+
492
+ function validateWebSurfaceSurfaces(config) {
493
+ const surfaces = config.surfaces || {};
494
+ for (const [name, surface] of Object.entries(surfaces)) {
495
+ if (!surface.pathOnly && !surface.previewUrlPattern) {
496
+ throw new Error(`surfaces.${name}.preview_url_pattern is required unless path_only = true`);
497
+ }
498
+ if (!surface.pathOnly && !surface.stagingUrl) {
499
+ throw new Error(`surfaces.${name}.staging_url is required unless path_only = true`);
500
+ }
501
+ if (!surface.pathOnly && !surface.productionUrl) {
502
+ throw new Error(`surfaces.${name}.production_url is required unless path_only = true`);
503
+ }
504
+ if (surface.productionUrl && !surface.pathOnly && !surface.stagingUrl) {
505
+ throw new Error(`surfaces.${name}.staging_url is required when production_url declares a first-class host`);
506
+ }
507
+ }
393
508
  }
394
509
 
395
510
  function normalizeVersionSection(version) {
@@ -734,6 +849,21 @@ export function validateBuildchainConfig(
734
849
  ]),
735
850
  )
736
851
  : undefined,
852
+ surfaces: loadedConfig.config.surfaces
853
+ ? Object.fromEntries(
854
+ Object.entries(loadedConfig.config.surfaces).map(([name, surface]) => [
855
+ name,
856
+ {
857
+ path: surface.path,
858
+ pathOnly: surface.pathOnly,
859
+ canonical: surface.canonical,
860
+ previewUrlPattern: surface.previewUrlPattern,
861
+ stagingUrl: surface.stagingUrl,
862
+ productionUrl: surface.productionUrl,
863
+ },
864
+ ]),
865
+ )
866
+ : undefined,
737
867
  retention: loadedConfig.config.retention,
738
868
  security: loadedConfig.config.security,
739
869
  version: loadedConfig.config.version
@@ -117,6 +117,38 @@ function resolveChannelUrl(channel, alias) {
117
117
  return channel.url;
118
118
  }
119
119
 
120
+ function resolvePathOnlyUrl(channel, sourcePath, alias) {
121
+ const base = resolveChannelUrl(channel, alias);
122
+ const url = new URL(base);
123
+ const normalizedPath = normalizeSurfacePath(sourcePath);
124
+ if (normalizedPath === "/") {
125
+ return `${url.origin}/`;
126
+ }
127
+ return new URL(normalizedPath.replace(/^\//, ""), `${url.origin}/`).toString();
128
+ }
129
+
130
+ function resolveSurfaceUrl({ surface, channel, channelName, alias }) {
131
+ if (channelName === "preview" && surface.previewUrlPattern) {
132
+ if (!alias) {
133
+ throw new Error(`surface ${surface.name} requires alias for preview_url_pattern`);
134
+ }
135
+ return surface.previewUrlPattern.replaceAll("{alias}", alias);
136
+ }
137
+ if (channelName === "staging" && surface.stagingUrl) {
138
+ return surface.stagingUrl;
139
+ }
140
+ if (channelName === "production" && surface.productionUrl) {
141
+ return surface.productionUrl;
142
+ }
143
+ if (surface.pathOnly) {
144
+ return resolvePathOnlyUrl(channel, surface.path, alias);
145
+ }
146
+ const key = channelName === "preview"
147
+ ? "preview_url_pattern"
148
+ : `${channelName}_url`;
149
+ throw new Error(`surfaces.${surface.name}.${key} is required for channel ${channelName}`);
150
+ }
151
+
120
152
  function manifestPrefixFor(deployConfig) {
121
153
  return deployConfig.manifest_prefix || ".buildchain/deployments";
122
154
  }
@@ -128,10 +160,51 @@ function objectPrefixFor(deployConfig, alias) {
128
160
  return alias || "preview";
129
161
  }
130
162
 
163
+ function normalizeSurfacePath(value) {
164
+ const normalized = `/${String(value || "/").replace(/^\/+/, "")}`.replace(/\/{2,}/g, "/");
165
+ if (normalized === "/") {
166
+ return normalized;
167
+ }
168
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
169
+ }
170
+
171
+ function surfaceDeployConfig(deployConfig, surfaceName) {
172
+ const overrides = deployConfig.surfaces?.[surfaceName];
173
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
174
+ return deployConfig;
175
+ }
176
+ return {
177
+ ...deployConfig,
178
+ ...overrides,
179
+ surfaces: deployConfig.surfaces,
180
+ secretRefs: [
181
+ ...new Set([
182
+ ...(deployConfig.secretRefs || []),
183
+ ...(Array.isArray(overrides.secretRefs) ? overrides.secretRefs : []),
184
+ ]),
185
+ ],
186
+ artifactPath: overrides.artifactPath || deployConfig.artifactPath,
187
+ };
188
+ }
189
+
190
+ function surfaceObjectPrefixFor({ deployConfig, surface, alias }) {
191
+ const effective = surfaceDeployConfig(deployConfig, surface.name);
192
+ if (Object.hasOwn(effective, "prefix")) {
193
+ return normalizeS3Key(effective.prefix);
194
+ }
195
+ const root = objectPrefixFor(deployConfig, alias);
196
+ const surfacePath = normalizeS3Key(surface.path);
197
+ if (!surfacePath) {
198
+ return root;
199
+ }
200
+ return joinS3Key(root, surfacePath);
201
+ }
202
+
131
203
  function normalizeS3Key(value) {
132
204
  return String(value || "")
133
205
  .replace(/^\/+/, "")
134
- .replace(/\/{2,}/g, "/");
206
+ .replace(/\/{2,}/g, "/")
207
+ .replace(/\/+$/, "");
135
208
  }
136
209
 
137
210
  function joinS3Key(...parts) {
@@ -268,6 +341,13 @@ function deployManifestKey(deployConfig, manifest) {
268
341
  return joinS3Key(manifestPrefixFor(deployConfig), `${manifest.alias || manifest.channel}.json`);
269
342
  }
270
343
 
344
+ function deploySurfaceManifestKey(deployConfig, binding) {
345
+ const suffix = binding.surface === "default"
346
+ ? `${binding.alias || binding.channel}.json`
347
+ : `${binding.alias || binding.channel}/${binding.surface}.json`;
348
+ return joinS3Key(manifestPrefixFor(deployConfig), suffix);
349
+ }
350
+
271
351
  function assertDeployPlan(plan) {
272
352
  if (plan?.contract !== "kungfu-buildchain-web-surface-deploy-plan") {
273
353
  throw new Error("web-surface deploy apply requires a deploy plan");
@@ -345,6 +425,55 @@ function retentionFor({ config, channelName, alias, deployedAt }) {
345
425
  };
346
426
  }
347
427
 
428
+ function configuredSurfaces(config) {
429
+ if (config.surfaces && Object.keys(config.surfaces).length > 0) {
430
+ return Object.values(config.surfaces);
431
+ }
432
+ return [{
433
+ name: "default",
434
+ path: "/",
435
+ pathOnly: false,
436
+ canonical: true,
437
+ }];
438
+ }
439
+
440
+ function resolveSurfaceBindings({ config, channelName, alias, deployConfig }) {
441
+ const channel = config.channels?.[channelName];
442
+ if (!channel) {
443
+ throw new Error(`unknown web-surface channel: ${channelName}`);
444
+ }
445
+ return configuredSurfaces(config).map((surface) => {
446
+ const effectiveDeploy = surfaceDeployConfig(deployConfig, surface.name);
447
+ const url = surface.name === "default" && !config.surfaces
448
+ ? resolveChannelUrl(channel, alias)
449
+ : resolveSurfaceUrl({ surface, channel, channelName, alias });
450
+ const objectPrefix = surface.name === "default" && !config.surfaces
451
+ ? objectPrefixFor(deployConfig, alias || channelName)
452
+ : surfaceObjectPrefixFor({ deployConfig, surface, alias: alias || channelName });
453
+ const bucket = effectiveDeploy.bucket || effectiveDeploy.target || "";
454
+ const distributionId = effectiveDeploy.cloudfront_distribution || effectiveDeploy.distribution || "";
455
+ return {
456
+ surface: surface.name,
457
+ channel: channelName,
458
+ alias,
459
+ url,
460
+ sourcePath: normalizeSurfacePath(surface.path),
461
+ canonicalUrl: surface.productionUrl || (channelName === "production" ? url : ""),
462
+ pathOnly: Boolean(surface.pathOnly),
463
+ bucket,
464
+ distributionId,
465
+ originPath: effectiveDeploy.origin_path || effectiveDeploy.originPath || "",
466
+ objectPrefix,
467
+ manifestKey: "",
468
+ noindex: channel.noindex,
469
+ accessControl: channel.accessControl,
470
+ };
471
+ }).map((binding) => ({
472
+ ...binding,
473
+ manifestKey: deploySurfaceManifestKey(deployConfig, binding),
474
+ }));
475
+ }
476
+
348
477
  export function validateWebSurfaceProject(cwd = process.cwd()) {
349
478
  const summary = validateBuildchainConfig(cwd, {
350
479
  requireConfig: true,
@@ -404,13 +533,20 @@ export function createWebSurfaceDeploymentManifest({
404
533
  throw new Error(`missing deploy.${channel}`);
405
534
  }
406
535
  const retention = retentionFor({ config, channelName: channel, alias, deployedAt });
536
+ const surfaceBindings = resolveSurfaceBindings({
537
+ config,
538
+ channelName: channel,
539
+ alias,
540
+ deployConfig,
541
+ });
542
+ const primaryBinding = surfaceBindings[0];
407
543
  return {
408
544
  schemaVersion: 1,
409
545
  contract: "kungfu-buildchain-web-surface-deployment",
410
546
  site: config.project.site || config.project.name || "",
411
547
  channel,
412
548
  alias,
413
- url: resolveChannelUrl(channelConfig, alias),
549
+ url: primaryBinding.url,
414
550
  sourceSha: assertSha(sourceSha, "sourceSha"),
415
551
  artifactHash: assertHash(artifactHash, "artifactHash"),
416
552
  deployTarget: deployConfig.target || deployConfig.bucket || deployConfig.environment || deployConfig.service || "",
@@ -437,6 +573,7 @@ export function createWebSurfaceDeploymentManifest({
437
573
  migrationState,
438
574
  rollbackPointer,
439
575
  rollbackLimitations,
576
+ surfaceBindings,
440
577
  };
441
578
  }
442
579
 
@@ -481,12 +618,14 @@ export function planWebSurfaceDeploy({
481
618
  channel,
482
619
  alias,
483
620
  url: manifest.url,
621
+ urls: Object.fromEntries(manifest.surfaceBindings.map((binding) => [binding.surface, binding.url])),
484
622
  artifact: {
485
623
  path: artifactPath || deployConfig.artifactPath || ".",
486
624
  hash: resolvedArtifact.artifactHash,
487
625
  files: resolvedArtifact.files,
488
626
  },
489
627
  manifest,
628
+ surfaceBindings: manifest.surfaceBindings,
490
629
  steps: planAdapterSteps(deployConfig.adapter, deployConfig, manifest),
491
630
  };
492
631
  }
@@ -526,39 +665,28 @@ export function applyWebSurfaceDeploy({
526
665
  if (resolvedPlan.adapter !== "aws-s3-cloudfront") {
527
666
  throw new Error(`web-surface deploy apply does not support adapter: ${resolvedPlan.adapter}`);
528
667
  }
529
- if (!dryRun) {
530
- assertConcreteAwsDeployConfig({
531
- deployConfig,
532
- channel: resolvedPlan.channel,
533
- operation: "deploy",
534
- });
535
- }
536
668
  const bucket = deployConfig.bucket || deployConfig.target || "";
537
669
  const artifactRoot = path.resolve(cwd, resolvedPlan.artifact.path);
538
- const objectPrefix = objectPrefixFor(deployConfig, resolvedPlan.manifest.alias || resolvedPlan.manifest.channel);
539
- const manifestKey = deployManifestKey(deployConfig, resolvedPlan.manifest);
540
- const invalidationPaths = [cdnWildcardPath(objectPrefix), cdnPath(manifestKey)];
541
- const operations = [
542
- {
543
- action: "sync-static-artifact",
544
- command: "aws",
545
- args: syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix }),
546
- },
547
- {
548
- action: "write-deployment-manifest",
549
- command: "aws",
550
- args: ["s3", "cp", "-", s3Uri(bucket, manifestKey), "--content-type", "application/json"],
551
- stdin: `${JSON.stringify(resolvedPlan.manifest, null, 2)}\n`,
552
- },
553
- ];
554
- const distribution = deployConfig.cloudfront_distribution || deployConfig.distribution || "";
555
- if (distribution) {
556
- operations.push({
557
- action: "invalidate-cdn",
558
- command: "aws",
559
- args: ["cloudfront", "create-invalidation", "--distribution-id", distribution, "--paths", ...invalidationPaths],
560
- });
670
+ const bindings = resolvedPlan.manifest.surfaceBindings || [];
671
+ if (!dryRun) {
672
+ for (const binding of bindings) {
673
+ assertConcreteAwsDeployConfig({
674
+ deployConfig: surfaceDeployConfig(deployConfig, binding.surface),
675
+ channel: `${resolvedPlan.channel}.surfaces.${binding.surface}`,
676
+ operation: "deploy",
677
+ });
678
+ }
561
679
  }
680
+ const operations = bindings.flatMap((binding) => deployBindingOperations({
681
+ artifactRoot,
682
+ deployConfig,
683
+ manifest: resolvedPlan.manifest,
684
+ binding,
685
+ }));
686
+ const primaryBinding = bindings[0] || {};
687
+ const objectPrefix = primaryBinding.objectPrefix || objectPrefixFor(deployConfig, resolvedPlan.manifest.alias || resolvedPlan.manifest.channel);
688
+ const manifestKey = primaryBinding.manifestKey || deployManifestKey(deployConfig, resolvedPlan.manifest);
689
+ const invalidationPaths = bindings.flatMap((binding) => [cdnWildcardPath(binding.objectPrefix), cdnPath(binding.manifestKey)]);
562
690
  const operationResults = runAdapterOperations({ operations, dryRun, commandRunner });
563
691
  return {
564
692
  schemaVersion: 1,
@@ -572,6 +700,7 @@ export function applyWebSurfaceDeploy({
572
700
  channel: resolvedPlan.channel,
573
701
  alias: resolvedPlan.alias,
574
702
  url: resolvedPlan.url,
703
+ urls: resolvedPlan.urls || Object.fromEntries(bindings.map((binding) => [binding.surface, binding.url])),
575
704
  sourceSha: resolvedPlan.manifest.sourceSha,
576
705
  artifactHash: resolvedPlan.artifact.hash,
577
706
  adapter: resolvedPlan.adapter,
@@ -580,26 +709,76 @@ export function applyWebSurfaceDeploy({
580
709
  manifestKey,
581
710
  invalidationPaths,
582
711
  manifest: resolvedPlan.manifest,
712
+ surfaceBindings: bindings,
583
713
  operations: operationResults,
584
714
  };
585
715
  }
586
716
 
717
+ function deployBindingOperations({ artifactRoot, deployConfig, manifest, binding }) {
718
+ const effectiveDeploy = surfaceDeployConfig(deployConfig, binding.surface);
719
+ const bucket = binding.bucket || effectiveDeploy.bucket || effectiveDeploy.target || "";
720
+ const distribution = binding.distributionId || effectiveDeploy.cloudfront_distribution || effectiveDeploy.distribution || "";
721
+ const operations = [
722
+ {
723
+ action: "sync-static-artifact",
724
+ surface: binding.surface,
725
+ command: "aws",
726
+ args: syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix: binding.objectPrefix }),
727
+ },
728
+ {
729
+ action: "write-deployment-manifest",
730
+ surface: binding.surface,
731
+ command: "aws",
732
+ args: ["s3", "cp", "-", s3Uri(bucket, binding.manifestKey), "--content-type", "application/json"],
733
+ stdin: `${JSON.stringify({
734
+ ...manifest,
735
+ surface: binding.surface,
736
+ url: binding.url,
737
+ surfaceBinding: binding,
738
+ }, null, 2)}\n`,
739
+ },
740
+ ];
741
+ if (distribution) {
742
+ operations.push({
743
+ action: "invalidate-cdn",
744
+ surface: binding.surface,
745
+ command: "aws",
746
+ args: [
747
+ "cloudfront",
748
+ "create-invalidation",
749
+ "--distribution-id",
750
+ distribution,
751
+ "--paths",
752
+ cdnWildcardPath(binding.objectPrefix),
753
+ cdnPath(binding.manifestKey),
754
+ ],
755
+ });
756
+ }
757
+ return operations;
758
+ }
759
+
587
760
  function planAdapterSteps(adapter, deployConfig, manifest) {
588
761
  if (adapter === "aws-s3-cloudfront") {
589
762
  return [
590
- {
591
- action: "sync-static-artifact",
592
- target: deployConfig.bucket || deployConfig.target || "",
593
- prefix: objectPrefixFor(deployConfig, manifest.alias || manifest.channel),
594
- },
595
- {
596
- action: "write-deployment-manifest",
597
- target: manifestPrefixFor(deployConfig),
598
- },
599
- {
600
- action: "invalidate-cdn",
601
- distribution: deployConfig.cloudfront_distribution || deployConfig.distribution || "",
602
- },
763
+ ...manifest.surfaceBindings.flatMap((binding) => [
764
+ {
765
+ action: "sync-static-artifact",
766
+ surface: binding.surface,
767
+ target: binding.bucket,
768
+ prefix: binding.objectPrefix,
769
+ },
770
+ {
771
+ action: "write-deployment-manifest",
772
+ surface: binding.surface,
773
+ target: manifestPrefixFor(deployConfig),
774
+ key: binding.manifestKey,
775
+ },
776
+ {
777
+ action: "invalidate-cdn",
778
+ surface: binding.surface,
779
+ distribution: binding.distributionId,
780
+ },
781
+ ]),
603
782
  ];
604
783
  }
605
784
  return [
@@ -640,6 +819,10 @@ export function planWebSurfaceCleanup({
640
819
  requestedAliases.push(`pr-${pullNumber}`);
641
820
  }
642
821
  const manifestPrefix = manifestPrefixFor(deployConfig);
822
+ const bindingsByAlias = new Map(requestedAliases.map((alias) => [
823
+ alias,
824
+ resolveSurfaceBindings({ config, channelName: channel, alias, deployConfig }),
825
+ ]));
643
826
  return {
644
827
  schemaVersion: 1,
645
828
  contract: "kungfu-buildchain-web-surface-cleanup-plan",
@@ -660,7 +843,9 @@ export function planWebSurfaceCleanup({
660
843
  entries: requestedAliases.map((alias) => {
661
844
  const classified = classifyPreviewAlias(alias);
662
845
  const retention = retentionConfig(config, "preview");
663
- const objectPrefix = objectPrefixFor(deployConfig, alias);
846
+ const surfaceBindings = bindingsByAlias.get(alias) || [];
847
+ const primaryBinding = surfaceBindings[0] || {};
848
+ const objectPrefix = primaryBinding.objectPrefix || objectPrefixFor(deployConfig, alias);
664
849
  return {
665
850
  alias,
666
851
  aliasKind: classified.kind,
@@ -669,11 +854,13 @@ export function planWebSurfaceCleanup({
669
854
  retentionDays: retention[classified.retentionKey],
670
855
  action: "delete-preview-alias",
671
856
  objectPrefix,
672
- manifestKey: `${manifestPrefix.replace(/\/$/, "")}/${alias}.json`,
857
+ manifestKey: primaryBinding.manifestKey || `${manifestPrefix.replace(/\/$/, "")}/${alias}.json`,
858
+ surfaceBindings,
673
859
  steps: cleanupAdapterSteps(deployConfig.adapter, deployConfig, {
674
860
  alias,
675
861
  objectPrefix,
676
862
  manifestPrefix,
863
+ surfaceBindings,
677
864
  }),
678
865
  };
679
866
  }),
@@ -718,33 +905,80 @@ export function applyWebSurfaceCleanup({
718
905
  throw new Error(`web-surface cleanup apply does not support adapter: ${cleanup.adapter}`);
719
906
  }
720
907
  if (!dryRun) {
721
- assertConcreteAwsDeployConfig({
722
- deployConfig,
723
- channel: cleanup.channel,
724
- operation: "cleanup",
725
- });
908
+ for (const entry of cleanup.entries) {
909
+ const bindings = entry.surfaceBindings?.length
910
+ ? entry.surfaceBindings
911
+ : [{
912
+ surface: "default",
913
+ }];
914
+ for (const binding of bindings) {
915
+ assertConcreteAwsDeployConfig({
916
+ deployConfig: surfaceDeployConfig(deployConfig, binding.surface),
917
+ channel: `${cleanup.channel}.surfaces.${binding.surface}`,
918
+ operation: "cleanup",
919
+ });
920
+ }
921
+ }
726
922
  }
727
923
  const bucket = deployConfig.bucket || deployConfig.target || "";
728
- const operations = cleanup.entries.flatMap((entry) => {
924
+ const operations = cleanup.entries.flatMap((entry) => cleanupEntryOperations({ deployConfig, entry, bucket }));
925
+ const operationResults = runAdapterOperations({ operations, dryRun, commandRunner });
926
+ return {
927
+ schemaVersion: 1,
928
+ contract: "kungfu-buildchain-web-surface-cleanup-apply",
929
+ dryRun,
930
+ applyMode: dryRun ? "dry-run" : "apply",
931
+ status: appliedStatus({ dryRun, operations: operationResults, noOp: cleanup.status === "no-op" }),
932
+ event,
933
+ channel,
934
+ now,
935
+ sourceSha: cleanup.sourceSha,
936
+ pullNumber: cleanup.pullNumber,
937
+ actor,
938
+ runId,
939
+ adapter: cleanup.adapter,
940
+ target: bucket,
941
+ manifestPrefix: cleanup.manifestPrefix,
942
+ entries: cleanup.entries,
943
+ operations: operationResults,
944
+ };
945
+ }
946
+
947
+ function cleanupEntryOperations({ deployConfig, entry, bucket }) {
948
+ const bindings = entry.surfaceBindings?.length
949
+ ? entry.surfaceBindings
950
+ : [{
951
+ surface: "default",
952
+ objectPrefix: entry.objectPrefix,
953
+ manifestKey: entry.manifestKey,
954
+ bucket,
955
+ distributionId: deployConfig.cloudfront_distribution || deployConfig.distribution || "",
956
+ }];
957
+ return bindings.flatMap((binding) => {
958
+ const effectiveDeploy = surfaceDeployConfig(deployConfig, binding.surface);
959
+ const bindingBucket = binding.bucket || effectiveDeploy.bucket || effectiveDeploy.target || bucket;
960
+ const distribution = binding.distributionId || effectiveDeploy.cloudfront_distribution || effectiveDeploy.distribution || "";
729
961
  const entryOperations = [
730
962
  {
731
963
  action: "delete-static-prefix",
732
964
  alias: entry.alias,
965
+ surface: binding.surface,
733
966
  command: "aws",
734
- args: ["s3", "rm", s3Uri(bucket, entry.objectPrefix), "--recursive"],
967
+ args: ["s3", "rm", s3Uri(bindingBucket, binding.objectPrefix), "--recursive"],
735
968
  },
736
969
  {
737
970
  action: "delete-deployment-manifest",
738
971
  alias: entry.alias,
972
+ surface: binding.surface,
739
973
  command: "aws",
740
- args: ["s3", "rm", s3Uri(bucket, entry.manifestKey)],
974
+ args: ["s3", "rm", s3Uri(bindingBucket, binding.manifestKey)],
741
975
  },
742
976
  ];
743
- const distribution = deployConfig.cloudfront_distribution || deployConfig.distribution || "";
744
977
  if (distribution) {
745
978
  entryOperations.push({
746
979
  action: "invalidate-cdn",
747
980
  alias: entry.alias,
981
+ surface: binding.surface,
748
982
  command: "aws",
749
983
  args: [
750
984
  "cloudfront",
@@ -752,53 +986,45 @@ export function applyWebSurfaceCleanup({
752
986
  "--distribution-id",
753
987
  distribution,
754
988
  "--paths",
755
- `${cdnPath(entry.objectPrefix)}/*`,
756
- cdnPath(entry.manifestKey),
989
+ `${cdnPath(binding.objectPrefix)}/*`,
990
+ cdnPath(binding.manifestKey),
757
991
  ],
758
992
  });
759
993
  }
760
994
  return entryOperations;
761
995
  });
762
- const operationResults = runAdapterOperations({ operations, dryRun, commandRunner });
763
- return {
764
- schemaVersion: 1,
765
- contract: "kungfu-buildchain-web-surface-cleanup-apply",
766
- dryRun,
767
- applyMode: dryRun ? "dry-run" : "apply",
768
- status: appliedStatus({ dryRun, operations: operationResults, noOp: cleanup.status === "no-op" }),
769
- event,
770
- channel,
771
- now,
772
- sourceSha: cleanup.sourceSha,
773
- pullNumber: cleanup.pullNumber,
774
- actor,
775
- runId,
776
- adapter: cleanup.adapter,
777
- target: bucket,
778
- manifestPrefix: cleanup.manifestPrefix,
779
- entries: cleanup.entries,
780
- operations: operationResults,
781
- };
782
996
  }
783
997
 
784
998
  function cleanupAdapterSteps(adapter, deployConfig, entry) {
785
999
  if (adapter === "aws-s3-cloudfront") {
786
- return [
787
- {
788
- action: "delete-static-prefix",
789
- target: deployConfig.bucket || deployConfig.target || "",
790
- prefix: entry.objectPrefix,
791
- },
792
- {
793
- action: "delete-deployment-manifest",
794
- target: entry.manifestPrefix,
795
- key: `${entry.manifestPrefix.replace(/\/$/, "")}/${entry.alias}.json`,
796
- },
797
- {
798
- action: "invalidate-cdn",
799
- distribution: deployConfig.cloudfront_distribution || deployConfig.distribution || "",
800
- },
801
- ];
1000
+ const bindings = entry.surfaceBindings?.length
1001
+ ? entry.surfaceBindings
1002
+ : [{
1003
+ surface: "default",
1004
+ objectPrefix: entry.objectPrefix,
1005
+ manifestKey: `${entry.manifestPrefix.replace(/\/$/, "")}/${entry.alias}.json`,
1006
+ bucket: deployConfig.bucket || deployConfig.target || "",
1007
+ distributionId: deployConfig.cloudfront_distribution || deployConfig.distribution || "",
1008
+ }];
1009
+ return bindings.flatMap((binding) => [
1010
+ {
1011
+ action: "delete-static-prefix",
1012
+ surface: binding.surface,
1013
+ target: binding.bucket,
1014
+ prefix: binding.objectPrefix,
1015
+ },
1016
+ {
1017
+ action: "delete-deployment-manifest",
1018
+ surface: binding.surface,
1019
+ target: entry.manifestPrefix,
1020
+ key: binding.manifestKey,
1021
+ },
1022
+ {
1023
+ action: "invalidate-cdn",
1024
+ surface: binding.surface,
1025
+ distribution: binding.distributionId,
1026
+ },
1027
+ ]);
802
1028
  }
803
1029
  return [
804
1030
  {
@@ -90,6 +90,7 @@ export function webSurfaceCli() {
90
90
  "web-surface-channel": result.channel,
91
91
  "web-surface-alias": result.alias,
92
92
  "web-surface-url": result.url,
93
+ "web-surface-urls-json": JSON.stringify(result.urls || {}),
93
94
  "web-surface-artifact-hash": result.artifact.hash,
94
95
  "web-surface-manifest-json": JSON.stringify(result.manifest),
95
96
  });
@@ -121,10 +122,12 @@ export function webSurfaceCli() {
121
122
  "web-surface-channel": result.channel,
122
123
  "web-surface-alias": result.alias,
123
124
  "web-surface-url": result.url,
125
+ "web-surface-urls-json": JSON.stringify(result.urls || {}),
124
126
  "web-surface-artifact-hash": result.artifactHash,
125
127
  "web-surface-apply-mode": result.applyMode,
126
128
  "web-surface-apply-status": result.status,
127
129
  "web-surface-manifest-json": JSON.stringify(result.manifest),
130
+ "web-surface-bindings-json": JSON.stringify(result.surfaceBindings || []),
128
131
  "web-surface-apply-result-json": JSON.stringify(result),
129
132
  });
130
133
  assertApplySucceeded(result);