@kungfu-tech/buildchain 2.0.14 → 2.0.15-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.
@@ -139,11 +139,11 @@ Dynamic adapters can also fill `runtimeId`, `configFingerprint`,
139
139
  `rollbackLimitations`. Buildchain records secret reference names only, never
140
140
  secret values.
141
141
 
142
- ## Dry-Run Deploy Plans
142
+ ## Deploy Plans
143
143
 
144
- The first implementation is deliberately dry-run only. It plans the adapter
145
- steps and writes manifest JSON, but it does not touch AWS, DNS, CloudFront, or
146
- deployment credentials.
144
+ Deploy planning is the default behavior. It plans the adapter steps and writes
145
+ manifest JSON, but it does not touch AWS, DNS, CloudFront, or deployment
146
+ credentials.
147
147
 
148
148
  ```bash
149
149
  node scripts/web-surface.mjs \
@@ -172,10 +172,77 @@ The CLI emits GitHub outputs when `GITHUB_OUTPUT` is present:
172
172
  - `web-surface-artifact-hash`
173
173
  - `web-surface-manifest-json`
174
174
 
175
+ ## Explicit Apply
176
+
177
+ `deploy-apply` and `cleanup-apply` are explicit execution modes for the
178
+ `aws-s3-cloudfront` static-site adapter. They still default to `--dry-run true`;
179
+ live AWS mutation requires `--dry-run false`.
180
+
181
+ Deploy apply syncs the artifact, writes the deployment manifest, and invalidates
182
+ CloudFront when a distribution id is configured:
183
+
184
+ ```bash
185
+ node scripts/web-surface.mjs \
186
+ --mode deploy-apply \
187
+ --cwd fixtures/web-surface-shaped \
188
+ --channel staging \
189
+ --source-sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
190
+ --artifact-path dist \
191
+ --dry-run false \
192
+ --output .buildchain/web-surface-staging-apply.json
193
+ ```
194
+
195
+ It can also execute a previously saved deploy plan. In that mode Buildchain
196
+ recomputes the local artifact hash before running AWS commands and fails closed
197
+ if the artifact no longer matches the saved plan:
198
+
199
+ ```bash
200
+ node scripts/web-surface.mjs \
201
+ --mode deploy-apply \
202
+ --cwd fixtures/web-surface-shaped \
203
+ --plan .buildchain/web-surface-staging-plan.json \
204
+ --dry-run false \
205
+ --output .buildchain/web-surface-staging-apply.json
206
+ ```
207
+
208
+ Cleanup apply deletes preview content, deletes the preview manifest, and
209
+ invalidates CloudFront:
210
+
211
+ ```bash
212
+ node scripts/web-surface.mjs \
213
+ --mode cleanup-apply \
214
+ --cwd fixtures/web-surface-shaped \
215
+ --event pull-request-closed \
216
+ --pull-number 123 \
217
+ --source-sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
218
+ --dry-run false \
219
+ --output .buildchain/web-surface-cleanup-apply.json
220
+ ```
221
+
222
+ Cleanup apply can also execute a saved cleanup plan:
223
+
224
+ ```bash
225
+ node scripts/web-surface.mjs \
226
+ --mode cleanup-apply \
227
+ --cwd fixtures/web-surface-shaped \
228
+ --plan .buildchain/web-surface-cleanup-plan.json \
229
+ --dry-run false \
230
+ --output .buildchain/web-surface-cleanup-apply.json
231
+ ```
232
+
233
+ 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`.
241
+
175
242
  ## Cleanup Plans
176
243
 
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
244
+ Preview cleanup is an auditable cleanup contract. It can run as a dry-run plan,
245
+ an apply-mode plan, or the explicit `cleanup-apply` executor with preview-only
179
246
  credentials:
180
247
 
181
248
  ```bash
@@ -187,11 +254,11 @@ node scripts/web-surface.mjs \
187
254
  --aliases pr-123,sha-abcdef123456
188
255
  ```
189
256
 
190
- The plan keeps mutable PR aliases and immutable SHA aliases distinct so a caller
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.
257
+ The plan and apply result keep mutable PR aliases and immutable SHA aliases
258
+ distinct so a caller can expire them with different retention windows. Closed-PR
259
+ cleanup can derive `pr-N` from `--pull-number`, records the event, source SHA,
260
+ actor, run id, preview bucket/prefix, manifest key, and adapter steps, and is an
261
+ auditable no-op when no aliases are requested.
195
262
 
196
263
  ## Reusable Workflow Shape
197
264
 
@@ -218,10 +285,10 @@ The reusable workflow maps GitHub events to Buildchain web-surface semantics:
218
285
  | `push` to `main` | validate, build, verify, and plan `staging` from the merged `main` SHA |
219
286
  | `workflow_dispatch` with `production-approved = true` | plan `production` and enter the configured GitHub Environment gate |
220
287
 
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`.
288
+ The workflow deliberately plans and emits manifests by default. Callers that
289
+ want live AWS mutation should invoke `deploy-apply` / `cleanup-apply` from a
290
+ controlled deploy job with scoped credentials. This keeps production from being
291
+ an implicit side effect of merging to `main`.
225
292
 
226
293
  ## Site Repository Shape
227
294
 
@@ -249,7 +316,9 @@ only needs a deterministic artifact path and the manifest facts.
249
316
 
250
317
  ## Boundaries
251
318
 
252
- Buildchain does not currently perform live AWS mutations for web surfaces.
253
- Production deploy, DNS changes, staging auth implementation, CloudFront
254
- distribution creation, and credential provisioning remain explicitly authorized
255
- operations outside the dry-run contract.
319
+ Buildchain only performs live AWS mutations in explicit apply modes with
320
+ `--dry-run false`. Production deploys must still be gated by a human-controlled
321
+ workflow, release, or GitHub Environment. DNS changes, staging auth
322
+ implementation, CloudFront distribution creation, and credential provisioning
323
+ remain explicitly authorized infrastructure operations outside the web-surface
324
+ artifact apply contract.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.0.14",
3
+ "version": "2.0.15-alpha.0",
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",
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -124,6 +125,152 @@ function objectPrefixFor(deployConfig, alias) {
124
125
  return deployConfig.prefix || alias || "preview";
125
126
  }
126
127
 
128
+ function normalizeS3Key(value) {
129
+ return String(value || "")
130
+ .replace(/^\/+/, "")
131
+ .replace(/\/{2,}/g, "/");
132
+ }
133
+
134
+ function joinS3Key(...parts) {
135
+ return normalizeS3Key(parts.filter(Boolean).join("/"));
136
+ }
137
+
138
+ function s3Uri(bucket, key = "") {
139
+ if (!bucket) {
140
+ throw new Error("aws-s3-cloudfront adapter requires a bucket or target");
141
+ }
142
+ const normalizedKey = normalizeS3Key(key);
143
+ return normalizedKey ? `s3://${bucket}/${normalizedKey}` : `s3://${bucket}`;
144
+ }
145
+
146
+ function cdnPath(value) {
147
+ const normalized = normalizeS3Key(value);
148
+ return normalized ? `/${normalized}` : "/";
149
+ }
150
+
151
+ function defaultCommandRunner({ command, args, stdin = "" }) {
152
+ const result = spawnSync(command, args, {
153
+ encoding: "utf8",
154
+ input: stdin,
155
+ });
156
+ if (result.error) {
157
+ throw result.error;
158
+ }
159
+ if (result.status !== 0) {
160
+ const stderr = String(result.stderr || "").trim();
161
+ throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}${stderr ? `: ${stderr}` : ""}`);
162
+ }
163
+ return {
164
+ exitCode: result.status,
165
+ stdout: String(result.stdout || ""),
166
+ stderr: String(result.stderr || ""),
167
+ };
168
+ }
169
+
170
+ function runAdapterOperation({ operation, dryRun, commandRunner }) {
171
+ if (dryRun) {
172
+ return {
173
+ ...operation,
174
+ status: "planned",
175
+ executed: false,
176
+ exitCode: null,
177
+ stdout: "",
178
+ stderr: "",
179
+ };
180
+ }
181
+ try {
182
+ const commandResult = commandRunner(operation);
183
+ const exitCode = commandResult?.exitCode ?? 0;
184
+ return {
185
+ ...operation,
186
+ status: exitCode === 0 ? "applied" : "failed",
187
+ executed: true,
188
+ exitCode,
189
+ stdout: commandResult?.stdout || "",
190
+ stderr: commandResult?.stderr || "",
191
+ };
192
+ } catch (error) {
193
+ return {
194
+ ...operation,
195
+ status: "failed",
196
+ executed: true,
197
+ exitCode: null,
198
+ stdout: "",
199
+ stderr: String(error.message || error),
200
+ };
201
+ }
202
+ }
203
+
204
+ function runAdapterOperations({ operations, dryRun, commandRunner }) {
205
+ const results = [];
206
+ for (const operation of operations) {
207
+ const result = runAdapterOperation({ operation, dryRun, commandRunner });
208
+ results.push(result);
209
+ if (result.status === "failed") {
210
+ break;
211
+ }
212
+ }
213
+ return results;
214
+ }
215
+
216
+ function appliedStatus({ dryRun, operations, noOp = false }) {
217
+ if (noOp) {
218
+ return "no-op";
219
+ }
220
+ if (operations.some((operation) => operation.status === "failed")) {
221
+ return "failed";
222
+ }
223
+ return dryRun ? "planned" : "applied";
224
+ }
225
+
226
+ function deployManifestKey(deployConfig, manifest) {
227
+ return joinS3Key(manifestPrefixFor(deployConfig), `${manifest.alias || manifest.channel}.json`);
228
+ }
229
+
230
+ function assertDeployPlan(plan) {
231
+ if (plan?.contract !== "kungfu-buildchain-web-surface-deploy-plan") {
232
+ throw new Error("web-surface deploy apply requires a deploy plan");
233
+ }
234
+ if (!plan.manifest) {
235
+ throw new Error("web-surface deploy plan is missing manifest");
236
+ }
237
+ if (!plan.artifact?.hash) {
238
+ throw new Error("web-surface deploy plan is missing artifact hash");
239
+ }
240
+ if (plan.manifest.artifactHash !== plan.artifact.hash) {
241
+ throw new Error("web-surface deploy plan artifact hash does not match manifest");
242
+ }
243
+ return plan;
244
+ }
245
+
246
+ function verifyDeployPlanArtifact({ cwd, plan }) {
247
+ const artifactPath = plan.artifact.path || ".";
248
+ const actual = createWebSurfaceArtifactHash({ cwd, artifactPath });
249
+ if (actual.artifactHash !== plan.artifact.hash) {
250
+ throw new Error(
251
+ `web-surface deploy plan artifact hash mismatch: expected ${plan.artifact.hash}, got ${actual.artifactHash}`,
252
+ );
253
+ }
254
+ return {
255
+ ...plan,
256
+ artifact: {
257
+ ...plan.artifact,
258
+ path: artifactPath,
259
+ files: actual.files,
260
+ },
261
+ };
262
+ }
263
+
264
+ function assertCleanupPlan(plan) {
265
+ if (plan?.contract !== "kungfu-buildchain-web-surface-cleanup-plan") {
266
+ throw new Error("web-surface cleanup apply requires a cleanup plan");
267
+ }
268
+ if (!Array.isArray(plan.entries)) {
269
+ throw new Error("web-surface cleanup plan is missing entries");
270
+ }
271
+ return plan;
272
+ }
273
+
127
274
  function retentionFor({ config, channelName, alias, deployedAt }) {
128
275
  if (channelName === "preview") {
129
276
  const classified = classifyPreviewAlias(alias);
@@ -303,6 +450,92 @@ export function planWebSurfaceDeploy({
303
450
  };
304
451
  }
305
452
 
453
+ export function applyWebSurfaceDeploy({
454
+ cwd = process.cwd(),
455
+ channel = "preview",
456
+ alias = "",
457
+ sourceSha = "",
458
+ artifactHash = "",
459
+ artifactPath = "",
460
+ plan = null,
461
+ dryRun = true,
462
+ actor = "",
463
+ runId = "",
464
+ appliedAt = new Date().toISOString(),
465
+ commandRunner = defaultCommandRunner,
466
+ } = {}) {
467
+ const resolvedPlan = plan
468
+ ? verifyDeployPlanArtifact({ cwd, plan: assertDeployPlan(plan) })
469
+ : planWebSurfaceDeploy({
470
+ cwd,
471
+ channel,
472
+ alias,
473
+ sourceSha,
474
+ artifactHash,
475
+ artifactPath,
476
+ dryRun: true,
477
+ deployedAt: appliedAt,
478
+ });
479
+ const loadedConfig = loadBuildchainConfig(cwd);
480
+ const config = assertWebSurfaceConfig(loadedConfig);
481
+ const deployConfig = config.deploy?.[resolvedPlan.channel];
482
+ if (!deployConfig) {
483
+ throw new Error(`missing deploy.${resolvedPlan.channel}`);
484
+ }
485
+ if (resolvedPlan.adapter !== "aws-s3-cloudfront") {
486
+ throw new Error(`web-surface deploy apply does not support adapter: ${resolvedPlan.adapter}`);
487
+ }
488
+ const bucket = deployConfig.bucket || deployConfig.target || "";
489
+ const artifactRoot = path.resolve(cwd, resolvedPlan.artifact.path);
490
+ const objectPrefix = objectPrefixFor(deployConfig, resolvedPlan.manifest.alias || resolvedPlan.manifest.channel);
491
+ const manifestKey = deployManifestKey(deployConfig, resolvedPlan.manifest);
492
+ const invalidationPaths = [`${cdnPath(objectPrefix)}/*`, cdnPath(manifestKey)];
493
+ const operations = [
494
+ {
495
+ action: "sync-static-artifact",
496
+ command: "aws",
497
+ args: ["s3", "sync", artifactRoot, s3Uri(bucket, objectPrefix), "--delete"],
498
+ },
499
+ {
500
+ action: "write-deployment-manifest",
501
+ command: "aws",
502
+ args: ["s3", "cp", "-", s3Uri(bucket, manifestKey), "--content-type", "application/json"],
503
+ stdin: `${JSON.stringify(resolvedPlan.manifest, null, 2)}\n`,
504
+ },
505
+ ];
506
+ const distribution = deployConfig.cloudfront_distribution || deployConfig.distribution || "";
507
+ if (distribution) {
508
+ operations.push({
509
+ action: "invalidate-cdn",
510
+ command: "aws",
511
+ args: ["cloudfront", "create-invalidation", "--distribution-id", distribution, "--paths", ...invalidationPaths],
512
+ });
513
+ }
514
+ const operationResults = runAdapterOperations({ operations, dryRun, commandRunner });
515
+ return {
516
+ schemaVersion: 1,
517
+ contract: "kungfu-buildchain-web-surface-deploy-apply",
518
+ dryRun,
519
+ applyMode: dryRun ? "dry-run" : "apply",
520
+ status: appliedStatus({ dryRun, operations: operationResults }),
521
+ actor,
522
+ runId,
523
+ appliedAt,
524
+ channel: resolvedPlan.channel,
525
+ alias: resolvedPlan.alias,
526
+ url: resolvedPlan.url,
527
+ sourceSha: resolvedPlan.manifest.sourceSha,
528
+ artifactHash: resolvedPlan.artifact.hash,
529
+ adapter: resolvedPlan.adapter,
530
+ target: bucket,
531
+ objectPrefix,
532
+ manifestKey,
533
+ invalidationPaths,
534
+ manifest: resolvedPlan.manifest,
535
+ operations: operationResults,
536
+ };
537
+ }
538
+
306
539
  function planAdapterSteps(adapter, deployConfig, manifest) {
307
540
  if (adapter === "aws-s3-cloudfront") {
308
541
  return [
@@ -399,6 +632,100 @@ export function planWebSurfaceCleanup({
399
632
  };
400
633
  }
401
634
 
635
+ export function applyWebSurfaceCleanup({
636
+ cwd = process.cwd(),
637
+ aliases = [],
638
+ channel = "preview",
639
+ now = new Date().toISOString(),
640
+ event = "manual",
641
+ sourceSha = "",
642
+ pullNumber = "",
643
+ actor = "",
644
+ runId = "",
645
+ plan = null,
646
+ dryRun = true,
647
+ commandRunner = defaultCommandRunner,
648
+ } = {}) {
649
+ const cleanup = plan
650
+ ? assertCleanupPlan(plan)
651
+ : planWebSurfaceCleanup({
652
+ cwd,
653
+ aliases,
654
+ channel,
655
+ now,
656
+ event,
657
+ sourceSha,
658
+ pullNumber,
659
+ actor,
660
+ runId,
661
+ dryRun,
662
+ });
663
+ const loadedConfig = loadBuildchainConfig(cwd);
664
+ const config = assertWebSurfaceConfig(loadedConfig);
665
+ const deployConfig = config.deploy?.[cleanup.channel];
666
+ if (!deployConfig) {
667
+ throw new Error(`missing deploy.${cleanup.channel}`);
668
+ }
669
+ if (cleanup.adapter !== "aws-s3-cloudfront") {
670
+ throw new Error(`web-surface cleanup apply does not support adapter: ${cleanup.adapter}`);
671
+ }
672
+ const bucket = deployConfig.bucket || deployConfig.target || "";
673
+ const operations = cleanup.entries.flatMap((entry) => {
674
+ const entryOperations = [
675
+ {
676
+ action: "delete-static-prefix",
677
+ alias: entry.alias,
678
+ command: "aws",
679
+ args: ["s3", "rm", s3Uri(bucket, entry.objectPrefix), "--recursive"],
680
+ },
681
+ {
682
+ action: "delete-deployment-manifest",
683
+ alias: entry.alias,
684
+ command: "aws",
685
+ args: ["s3", "rm", s3Uri(bucket, entry.manifestKey)],
686
+ },
687
+ ];
688
+ const distribution = deployConfig.cloudfront_distribution || deployConfig.distribution || "";
689
+ if (distribution) {
690
+ entryOperations.push({
691
+ action: "invalidate-cdn",
692
+ alias: entry.alias,
693
+ command: "aws",
694
+ args: [
695
+ "cloudfront",
696
+ "create-invalidation",
697
+ "--distribution-id",
698
+ distribution,
699
+ "--paths",
700
+ `${cdnPath(entry.objectPrefix)}/*`,
701
+ cdnPath(entry.manifestKey),
702
+ ],
703
+ });
704
+ }
705
+ return entryOperations;
706
+ });
707
+ const operationResults = runAdapterOperations({ operations, dryRun, commandRunner });
708
+ return {
709
+ schemaVersion: 1,
710
+ contract: "kungfu-buildchain-web-surface-cleanup-apply",
711
+ dryRun,
712
+ applyMode: dryRun ? "dry-run" : "apply",
713
+ status: appliedStatus({ dryRun, operations: operationResults, noOp: cleanup.status === "no-op" }),
714
+ event,
715
+ channel,
716
+ now,
717
+ sourceSha: cleanup.sourceSha,
718
+ pullNumber: cleanup.pullNumber,
719
+ actor,
720
+ runId,
721
+ adapter: cleanup.adapter,
722
+ target: bucket,
723
+ manifestPrefix: cleanup.manifestPrefix,
724
+ entries: cleanup.entries,
725
+ operations: operationResults,
726
+ };
727
+ }
728
+
402
729
  function cleanupAdapterSteps(adapter, deployConfig, entry) {
403
730
  if (adapter === "aws-s3-cloudfront") {
404
731
  return [
@@ -3,6 +3,8 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import {
6
+ applyWebSurfaceCleanup,
7
+ applyWebSurfaceDeploy,
6
8
  defaultWebSurfaceAlias,
7
9
  planWebSurfaceCleanup,
8
10
  planWebSurfaceDeploy,
@@ -36,6 +38,20 @@ function writeJson(result, outputPath) {
36
38
  }
37
39
  }
38
40
 
41
+ function readJsonFileArg(name) {
42
+ const value = readArg(name, "");
43
+ if (!value) {
44
+ return null;
45
+ }
46
+ return JSON.parse(fs.readFileSync(path.resolve(value), "utf8"));
47
+ }
48
+
49
+ function assertApplySucceeded(result) {
50
+ if (result.status === "failed") {
51
+ throw new Error(`web-surface ${result.contract} failed; see apply result for operation details`);
52
+ }
53
+ }
54
+
39
55
  export function webSurfaceCli() {
40
56
  const mode = readArg("mode", process.env.BUILDCHAIN_WEB_SURFACE_MODE || "validate");
41
57
  const cwd = readArg("cwd", process.env.BUILDCHAIN_WORKDIR || process.cwd());
@@ -80,6 +96,40 @@ export function webSurfaceCli() {
80
96
  return outputResult;
81
97
  }
82
98
 
99
+ if (mode === "deploy-apply") {
100
+ const plan = readJsonFileArg("plan");
101
+ const alias = plan
102
+ ? readArg("alias", process.env.BUILDCHAIN_WEB_SURFACE_ALIAS || "")
103
+ : readArg(
104
+ "alias",
105
+ process.env.BUILDCHAIN_WEB_SURFACE_ALIAS || defaultWebSurfaceAlias({ channel, sourceSha, pullNumber }),
106
+ );
107
+ const result = applyWebSurfaceDeploy({
108
+ cwd,
109
+ channel,
110
+ alias,
111
+ sourceSha,
112
+ artifactHash: readArg("artifact-hash", process.env.BUILDCHAIN_WEB_SURFACE_ARTIFACT_HASH || ""),
113
+ artifactPath: readArg("artifact-path", process.env.BUILDCHAIN_WEB_SURFACE_ARTIFACT_PATH || ""),
114
+ plan,
115
+ dryRun: readBooleanArg("dry-run", true),
116
+ actor: readArg("actor", process.env.GITHUB_ACTOR || ""),
117
+ runId: readArg("run-id", process.env.GITHUB_RUN_ID || ""),
118
+ });
119
+ writeJson(result, output);
120
+ writeGitHubOutputs({
121
+ "web-surface-channel": result.channel,
122
+ "web-surface-alias": result.alias,
123
+ "web-surface-url": result.url,
124
+ "web-surface-artifact-hash": result.artifactHash,
125
+ "web-surface-apply-mode": result.applyMode,
126
+ "web-surface-apply-status": result.status,
127
+ "web-surface-manifest-json": JSON.stringify(result.manifest),
128
+ });
129
+ assertApplySucceeded(result);
130
+ return result;
131
+ }
132
+
83
133
  if (mode === "cleanup-plan") {
84
134
  const aliases = readArg("aliases", process.env.BUILDCHAIN_WEB_SURFACE_ALIASES || "")
85
135
  .split(",")
@@ -106,6 +156,35 @@ export function webSurfaceCli() {
106
156
  return result;
107
157
  }
108
158
 
159
+ if (mode === "cleanup-apply") {
160
+ const plan = readJsonFileArg("plan");
161
+ const aliases = readArg("aliases", process.env.BUILDCHAIN_WEB_SURFACE_ALIASES || "")
162
+ .split(",")
163
+ .map((entry) => entry.trim())
164
+ .filter(Boolean);
165
+ const result = applyWebSurfaceCleanup({
166
+ cwd,
167
+ channel,
168
+ aliases,
169
+ sourceSha,
170
+ pullNumber,
171
+ event: readArg("event", process.env.BUILDCHAIN_WEB_SURFACE_EVENT || "manual"),
172
+ actor: readArg("actor", process.env.GITHUB_ACTOR || ""),
173
+ runId: readArg("run-id", process.env.GITHUB_RUN_ID || ""),
174
+ plan,
175
+ dryRun: readBooleanArg("dry-run", true),
176
+ });
177
+ writeJson(result, output);
178
+ writeGitHubOutputs({
179
+ "web-surface-cleanup-count": String(result.entries.length),
180
+ "web-surface-cleanup-mode": result.applyMode,
181
+ "web-surface-cleanup-status": result.status,
182
+ "web-surface-cleanup-result-json": JSON.stringify(result),
183
+ });
184
+ assertApplySucceeded(result);
185
+ return result;
186
+ }
187
+
109
188
  throw new Error(`unsupported web-surface mode: ${mode}`);
110
189
  }
111
190