@kungfu-tech/buildchain 2.0.14 → 2.0.15-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/web-surface-deployments.md +88 -19
- package/package.json +1 -1
- package/scripts/web-surface-core.mjs +327 -0
- package/scripts/web-surface.mjs +79 -0
|
@@ -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
|
-
##
|
|
142
|
+
## Deploy Plans
|
|
143
143
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
can expire them with different retention windows. Closed-PR
|
|
192
|
-
`pr-N` from `--pull-number`, records the event, source SHA,
|
|
193
|
-
preview bucket/prefix, manifest key, and adapter steps, and is an
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
253
|
-
Production
|
|
254
|
-
|
|
255
|
-
|
|
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.
|
|
3
|
+
"version": "2.0.15-alpha.1",
|
|
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 [
|
package/scripts/web-surface.mjs
CHANGED
|
@@ -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
|
|