@kungfu-tech/buildchain 2.0.14-alpha.2 → 2.0.14-alpha.3

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/README.md CHANGED
@@ -104,6 +104,9 @@ Buildchain v2 ships these active surfaces:
104
104
  commands, trusted event gating, artifact name templates, expected artifact
105
105
  checks, deterministic artifact manifests, publish-gate source locks, resolved
106
106
  release manifests, and aggregate build summaries;
107
+ - `.github/workflows/.web-surface.yml` as the reusable site/app surface for PR
108
+ preview plans, closed-PR preview cleanup plans, push-main staging promotion
109
+ plans, and explicit production environment gates;
107
110
  - `actions/run-lifecycle` for callers that need the same lifecycle/manifest
108
111
  contract inside their own workflows;
109
112
  - governance-closed self-promotion through `Buildchain Ref Promotion`.
@@ -287,7 +287,8 @@ noindex = true
287
287
  [channels.staging]
288
288
  url = "https://staging.kungfu.tech"
289
289
  visibility = "protected"
290
- requires_auth = true
290
+ access_control = "managed-network"
291
+ edge_auth = "none"
291
292
  noindex = true
292
293
  promotable = true
293
294
 
@@ -42,7 +42,8 @@ noindex = true
42
42
  [channels.staging]
43
43
  url = "https://staging.kungfu.tech"
44
44
  visibility = "protected"
45
- requires_auth = true
45
+ access_control = "managed-network"
46
+ edge_auth = "none"
46
47
  noindex = true
47
48
  promotable = true
48
49
 
@@ -64,7 +65,11 @@ Buildchain validates these hard constraints:
64
65
 
65
66
  - `channels.preview.url_pattern` is required and must contain the alias shape
66
67
  used by preview deployments.
67
- - `channels.staging.requires_auth = true` is required.
68
+ - `channels.staging.access_control` must protect staging. Supported modes are
69
+ `managed-network`, `edge-basic-auth`, `oidc`, and `app-auth`.
70
+ - `channels.staging.edge_auth` records whether the edge layer owns auth. Use
71
+ `edge_auth = "none"` when staging is protected by managed network controls
72
+ such as WAF/IP allowlists or VPN access.
68
73
  - `channels.staging.noindex = true` is required.
69
74
  - `channels.production.url` is required.
70
75
  - deploy adapters must be declared per channel.
@@ -122,6 +127,9 @@ rollback:
122
127
  "deployedAt": "2026-07-01T00:00:00.000Z",
123
128
  "retentionClass": "preview-sha-immutable",
124
129
  "expiresAt": "2026-09-29T00:00:00.000Z",
130
+ "accessControl": "none",
131
+ "edgeAuth": "none",
132
+ "noindex": true,
125
133
  "secretRefs": ["AWS_ROLE_ARN"]
126
134
  }
127
135
  ```
@@ -166,17 +174,54 @@ The CLI emits GitHub outputs when `GITHUB_OUTPUT` is present:
166
174
 
167
175
  ## Cleanup Plans
168
176
 
169
- Preview cleanup is also a dry-run plan:
177
+ Preview cleanup is an auditable cleanup contract. It can run as a dry-run plan
178
+ or as an apply-mode plan for the caller workflow to execute with preview-only
179
+ credentials:
170
180
 
171
181
  ```bash
172
182
  node scripts/web-surface.mjs \
173
183
  --mode cleanup-plan \
174
184
  --cwd fixtures/web-surface-shaped \
185
+ --event pull-request-closed \
186
+ --pull-number 123 \
175
187
  --aliases pr-123,sha-abcdef123456
176
188
  ```
177
189
 
178
190
  The plan keeps mutable PR aliases and immutable SHA aliases distinct so a caller
179
- can expire them with different retention windows.
191
+ can expire them with different retention windows. Closed-PR cleanup can derive
192
+ `pr-N` from `--pull-number`, records the event, source SHA, actor, run id,
193
+ preview bucket/prefix, manifest key, and adapter steps, and is an auditable
194
+ no-op when no aliases are requested.
195
+
196
+ ## Reusable Workflow Shape
197
+
198
+ Buildchain ships `.github/workflows/.web-surface.yml` for repositories that want
199
+ the standard PR review and promotion flow without copying bespoke glue:
200
+
201
+ ```yaml
202
+ jobs:
203
+ web-surface:
204
+ uses: kungfu-systems/buildchain/.github/workflows/.web-surface.yml@v2
205
+ with:
206
+ buildchain-ref: v2
207
+ build-command: npm run build
208
+ verify-command: npm run check
209
+ artifact-path: dist
210
+ ```
211
+
212
+ The reusable workflow maps GitHub events to Buildchain web-surface semantics:
213
+
214
+ | Event | Buildchain behavior |
215
+ | --- | --- |
216
+ | `pull_request` opened / synchronized / reopened | validate, build, verify, and plan `preview` for `pr-N` |
217
+ | `pull_request` closed | plan apply-mode cleanup for the `pr-N` preview alias and manifest |
218
+ | `push` to `main` | validate, build, verify, and plan `staging` from the merged `main` SHA |
219
+ | `workflow_dispatch` with `production-approved = true` | plan `production` and enter the configured GitHub Environment gate |
220
+
221
+ The workflow deliberately plans and emits manifests; live AWS mutation still
222
+ belongs to the caller's controlled deploy step or a later adapter apply
223
+ implementation. This keeps production from being an implicit side effect of
224
+ merging to `main`.
180
225
 
181
226
  ## Site Repository Shape
182
227
 
@@ -208,4 +253,3 @@ Buildchain does not currently perform live AWS mutations for web surfaces.
208
253
  Production deploy, DNS changes, staging auth implementation, CloudFront
209
254
  distribution creation, and credential provisioning remain explicitly authorized
210
255
  operations outside the dry-run contract.
211
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.0.14-alpha.2",
3
+ "version": "2.0.14-alpha.3",
4
4
  "private": false,
5
5
  "description": "Kungfu Buildchain reusable workflows, release governance, and CLI tooling.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -12,6 +12,8 @@ const SUPPORTED_VERSION_NEXT = new Set(["auto", "manual"]);
12
12
  const SUPPORTED_PROJECT_TYPES = new Set(["package", "web-surface"]);
13
13
  const WEB_SURFACE_CHANNELS = ["preview", "staging", "production"];
14
14
  const SUPPORTED_CHANNEL_VISIBILITY = new Set(["ephemeral", "protected", "public", "internal"]);
15
+ const SUPPORTED_ACCESS_CONTROL = new Set(["none", "managed-network", "edge-basic-auth", "oidc", "app-auth"]);
16
+ const SUPPORTED_EDGE_AUTH = new Set(["none", "cloudfront-basic-auth", "oidc", "app-auth"]);
15
17
  const SUPPORTED_DEPLOY_ADAPTERS = new Set([
16
18
  "aws-s3-cloudfront",
17
19
  "aws-elastic-beanstalk",
@@ -159,13 +161,18 @@ function normalizeChannelConfig(name, channel) {
159
161
  if (!SUPPORTED_CHANNEL_VISIBILITY.has(visibility)) {
160
162
  throw new Error(`channels.${name}.visibility must be one of ephemeral, protected, public, or internal`);
161
163
  }
164
+ const accessControl = normalizeAccessControl(channel, name);
165
+ const edgeAuth = normalizeEdgeAuth(channel, name, accessControl);
162
166
  const normalized = {
163
167
  name,
164
168
  visibility,
165
- requiresAuth: optionalBoolean(channel.requires_auth, name === "staging"),
169
+ requiresAuth: optionalBoolean(channel.requires_auth, accessControl !== "none"),
170
+ requiresControlledAccess: accessControl !== "none",
166
171
  noindex: optionalBoolean(channel.noindex, name !== "production"),
167
172
  promotable: optionalBoolean(channel.promotable, name === "staging"),
168
173
  canonical: optionalBoolean(channel.canonical, name === "production"),
174
+ accessControl,
175
+ edgeAuth,
169
176
  };
170
177
  if (hasUrl) {
171
178
  normalized.url = assertString(channel.url, `channels.${name}.url`);
@@ -176,6 +183,36 @@ function normalizeChannelConfig(name, channel) {
176
183
  return normalized;
177
184
  }
178
185
 
186
+ function normalizeAccessControl(channel, name) {
187
+ if (channel.access_control !== undefined) {
188
+ const accessControl = assertString(channel.access_control, `channels.${name}.access_control`);
189
+ if (!SUPPORTED_ACCESS_CONTROL.has(accessControl)) {
190
+ throw new Error(`channels.${name}.access_control must be one of none, managed-network, edge-basic-auth, oidc, or app-auth`);
191
+ }
192
+ return accessControl;
193
+ }
194
+ if (name === "staging") {
195
+ return "edge-basic-auth";
196
+ }
197
+ if (name === "production") {
198
+ return "none";
199
+ }
200
+ return "none";
201
+ }
202
+
203
+ function normalizeEdgeAuth(channel, name, accessControl) {
204
+ if (channel.edge_auth !== undefined) {
205
+ const edgeAuth = assertString(channel.edge_auth, `channels.${name}.edge_auth`);
206
+ if (!SUPPORTED_EDGE_AUTH.has(edgeAuth)) {
207
+ throw new Error(`channels.${name}.edge_auth must be one of none, cloudfront-basic-auth, oidc, or app-auth`);
208
+ }
209
+ return edgeAuth;
210
+ }
211
+ return accessControl === "edge-basic-auth"
212
+ ? "cloudfront-basic-auth"
213
+ : "none";
214
+ }
215
+
179
216
  function defaultChannelVisibility(name) {
180
217
  if (name === "preview") {
181
218
  return "ephemeral";
@@ -290,17 +327,20 @@ function validateWebSurfaceConfig(config) {
290
327
  throw new Error(`channels.${name}.url is required for web-surface`);
291
328
  }
292
329
  }
293
- if (!config.channels.staging.requiresAuth) {
294
- throw new Error("channels.staging.requires_auth must be true for web-surface");
330
+ if (config.channels.staging.visibility === "public") {
331
+ throw new Error("channels.staging.visibility must not be public for web-surface");
332
+ }
333
+ if (config.channels.staging.accessControl === "none") {
334
+ throw new Error("channels.staging.access_control must protect staging for web-surface");
335
+ }
336
+ if (config.channels.staging.accessControl === "edge-basic-auth" && config.channels.staging.edgeAuth === "none") {
337
+ throw new Error("channels.staging.edge_auth must not be none when access_control = edge-basic-auth");
295
338
  }
296
339
  if (!config.channels.staging.noindex) {
297
340
  throw new Error("channels.staging.noindex must be true for web-surface");
298
341
  }
299
342
  const stagingSecurity = config.security?.staging;
300
343
  if (stagingSecurity) {
301
- if (stagingSecurity.requiresAuth === false) {
302
- throw new Error("security.staging.requires_auth must not disable staging auth");
303
- }
304
344
  if (stagingSecurity.noindex === false) {
305
345
  throw new Error("security.staging.noindex must not disable staging noindex");
306
346
  }
@@ -624,6 +664,9 @@ export function validateBuildchainConfig(
624
664
  {
625
665
  visibility: channel.visibility,
626
666
  requiresAuth: channel.requiresAuth,
667
+ requiresControlledAccess: channel.requiresControlledAccess,
668
+ accessControl: channel.accessControl,
669
+ edgeAuth: channel.edgeAuth,
627
670
  noindex: channel.noindex,
628
671
  promotable: channel.promotable,
629
672
  canonical: channel.canonical,
@@ -163,7 +163,8 @@ noindex = true
163
163
  [channels.staging]
164
164
  url = "https://staging.example.com"
165
165
  visibility = "protected"
166
- requires_auth = true
166
+ access_control = "managed-network"
167
+ edge_auth = "none"
167
168
  noindex = true
168
169
  promotable = true
169
170
 
@@ -193,7 +194,6 @@ bucket_ref = "AWS_PRODUCTION_BUCKET"
193
194
  distribution_ref = "AWS_PRODUCTION_DISTRIBUTION"
194
195
 
195
196
  [security.staging]
196
- requires_auth = true
197
197
  noindex = true
198
198
  isolated_providers = true
199
199
 
@@ -116,6 +116,14 @@ function resolveChannelUrl(channel, alias) {
116
116
  return channel.url;
117
117
  }
118
118
 
119
+ function manifestPrefixFor(deployConfig) {
120
+ return deployConfig.manifest_prefix || ".buildchain/deployments";
121
+ }
122
+
123
+ function objectPrefixFor(deployConfig, alias) {
124
+ return deployConfig.prefix || alias || "preview";
125
+ }
126
+
119
127
  function retentionFor({ config, channelName, alias, deployedAt }) {
120
128
  if (channelName === "preview") {
121
129
  const classified = classifyPreviewAlias(alias);
@@ -224,6 +232,11 @@ export function createWebSurfaceDeploymentManifest({
224
232
  retentionClass: retention.retentionClass,
225
233
  expiresAt: retention.expiresAt,
226
234
  mutableAlias: retention.mutableAlias,
235
+ accessControl: channelConfig.accessControl,
236
+ edgeAuth: channelConfig.edgeAuth,
237
+ noindex: channelConfig.noindex,
238
+ promotable: channelConfig.promotable,
239
+ canonical: channelConfig.canonical,
227
240
  runtimeId,
228
241
  configFingerprint,
229
242
  secretRefs: [
@@ -296,11 +309,11 @@ function planAdapterSteps(adapter, deployConfig, manifest) {
296
309
  {
297
310
  action: "sync-static-artifact",
298
311
  target: deployConfig.bucket || deployConfig.target || "",
299
- prefix: deployConfig.prefix || manifest.alias || manifest.channel,
312
+ prefix: objectPrefixFor(deployConfig, manifest.alias || manifest.channel),
300
313
  },
301
314
  {
302
315
  action: "write-deployment-manifest",
303
- target: deployConfig.manifest_prefix || ".buildchain/deployments",
316
+ target: manifestPrefixFor(deployConfig),
304
317
  },
305
318
  {
306
319
  action: "invalidate-cdn",
@@ -315,7 +328,7 @@ function planAdapterSteps(adapter, deployConfig, manifest) {
315
328
  },
316
329
  {
317
330
  action: "write-deployment-manifest",
318
- target: deployConfig.manifest_prefix || ".buildchain/deployments",
331
+ target: manifestPrefixFor(deployConfig),
319
332
  },
320
333
  ];
321
334
  }
@@ -325,37 +338,100 @@ export function planWebSurfaceCleanup({
325
338
  aliases = [],
326
339
  channel = "preview",
327
340
  now = new Date().toISOString(),
341
+ event = "manual",
342
+ sourceSha = "",
343
+ pullNumber = "",
344
+ actor = "",
345
+ runId = "",
328
346
  dryRun = true,
329
347
  } = {}) {
330
- if (!dryRun) {
331
- throw new Error("web-surface cleanup currently supports dry-run planning only");
332
- }
333
348
  const loadedConfig = loadBuildchainConfig(cwd);
334
349
  const config = assertWebSurfaceConfig(loadedConfig);
335
350
  if (channel !== "preview") {
336
- throw new Error("web-surface cleanup plan currently supports preview aliases only");
351
+ throw new Error("web-surface cleanup currently supports preview aliases only");
352
+ }
353
+ const deployConfig = config.deploy?.[channel];
354
+ if (!deployConfig) {
355
+ throw new Error(`missing deploy.${channel}`);
356
+ }
357
+ const requestedAliases = [...aliases];
358
+ if (requestedAliases.length === 0 && pullNumber) {
359
+ requestedAliases.push(`pr-${pullNumber}`);
337
360
  }
361
+ const manifestPrefix = manifestPrefixFor(deployConfig);
338
362
  return {
339
363
  schemaVersion: 1,
340
364
  contract: "kungfu-buildchain-web-surface-cleanup-plan",
341
- dryRun: true,
365
+ dryRun,
366
+ applyMode: dryRun ? "dry-run" : "apply",
367
+ event,
342
368
  channel,
343
369
  now,
344
- entries: aliases.map((alias) => {
370
+ sourceSha: sourceSha ? assertSha(sourceSha, "sourceSha") : "",
371
+ pullNumber: pullNumber ? String(pullNumber) : "",
372
+ actor,
373
+ runId,
374
+ status: requestedAliases.length === 0 ? "no-op" : "planned",
375
+ adapter: deployConfig.adapter,
376
+ target: deployConfig.bucket || deployConfig.target || "",
377
+ manifestPrefix,
378
+ secretRefs: [...new Set([...(deployConfig.secretRefs || [])])],
379
+ entries: requestedAliases.map((alias) => {
345
380
  const classified = classifyPreviewAlias(alias);
346
381
  const retention = retentionConfig(config, "preview");
382
+ const objectPrefix = objectPrefixFor(deployConfig, alias);
347
383
  return {
348
384
  alias,
349
385
  aliasKind: classified.kind,
350
386
  mutableAlias: classified.mutable,
351
387
  retentionClass: classified.retentionClass,
352
388
  retentionDays: retention[classified.retentionKey],
353
- action: "expire-preview-alias",
389
+ action: "delete-preview-alias",
390
+ objectPrefix,
391
+ manifestKey: `${manifestPrefix.replace(/\/$/, "")}/${alias}.json`,
392
+ steps: cleanupAdapterSteps(deployConfig.adapter, deployConfig, {
393
+ alias,
394
+ objectPrefix,
395
+ manifestPrefix,
396
+ }),
354
397
  };
355
398
  }),
356
399
  };
357
400
  }
358
401
 
402
+ function cleanupAdapterSteps(adapter, deployConfig, entry) {
403
+ if (adapter === "aws-s3-cloudfront") {
404
+ return [
405
+ {
406
+ action: "delete-static-prefix",
407
+ target: deployConfig.bucket || deployConfig.target || "",
408
+ prefix: entry.objectPrefix,
409
+ },
410
+ {
411
+ action: "delete-deployment-manifest",
412
+ target: entry.manifestPrefix,
413
+ key: `${entry.manifestPrefix.replace(/\/$/, "")}/${entry.alias}.json`,
414
+ },
415
+ {
416
+ action: "invalidate-cdn",
417
+ distribution: deployConfig.cloudfront_distribution || deployConfig.distribution || "",
418
+ },
419
+ ];
420
+ }
421
+ return [
422
+ {
423
+ action: "delete-preview-environment",
424
+ target: deployConfig.environment || deployConfig.service || deployConfig.target || "",
425
+ alias: entry.alias,
426
+ },
427
+ {
428
+ action: "delete-deployment-manifest",
429
+ target: entry.manifestPrefix,
430
+ key: `${entry.manifestPrefix.replace(/\/$/, "")}/${entry.alias}.json`,
431
+ },
432
+ ];
433
+ }
434
+
359
435
  export function defaultWebSurfaceAlias({ channel = "preview", sourceSha = "", pullNumber = "" } = {}) {
360
436
  if (channel !== "preview") {
361
437
  return "";
@@ -89,11 +89,18 @@ export function webSurfaceCli() {
89
89
  cwd,
90
90
  channel,
91
91
  aliases,
92
+ sourceSha,
93
+ pullNumber,
94
+ event: readArg("event", process.env.BUILDCHAIN_WEB_SURFACE_EVENT || "manual"),
95
+ actor: readArg("actor", process.env.GITHUB_ACTOR || ""),
96
+ runId: readArg("run-id", process.env.GITHUB_RUN_ID || ""),
92
97
  dryRun: readBooleanArg("dry-run", true),
93
98
  });
94
99
  writeJson(result, output);
95
100
  writeGitHubOutputs({
96
101
  "web-surface-cleanup-count": String(result.entries.length),
102
+ "web-surface-cleanup-mode": result.applyMode,
103
+ "web-surface-cleanup-status": result.status,
97
104
  "web-surface-cleanup-plan-json": JSON.stringify(result),
98
105
  });
99
106
  return result;