@kungfu-tech/buildchain 3.0.4 → 3.0.5-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/dist/site/buildchain-contract.json +23 -18
- package/dist/site/buildchain-site.json +29 -24
- package/dist/site/controller-registry.json +6 -2
- package/dist/site/kfd-claims.json +6 -4
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +6 -6
- package/dist/site/node-api-registry.json +96 -5
- package/dist/site/page-registry.json +19 -14
- package/dist/site/public-surface-audit.json +12 -8
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +10 -10
- package/dist/site/workflow-registry.json +10 -6
- package/docs/auditable-demo.md +7 -0
- package/docs/aws-us-elastic-runner-burst-plane.md +32 -1
- package/docs/node-api-reference.md +12 -3
- package/docs/publish-transaction.md +7 -5
- package/docs/release-flow.md +4 -0
- package/docs/release-governance.md +7 -0
- package/docs/release-propagation.md +34 -0
- package/package.json +1 -1
- package/packages/core/release-propagation-capture.js +150 -0
- package/packages/core/release-propagation-stage-evidence.js +1 -1
- package/packages/core/release-propagation.js +5 -0
- package/scripts/auditable-demo-renditions.mjs +2 -3
- package/scripts/aws-macos-jit-controller-core.mjs +389 -0
- package/scripts/aws-macos-jit-controller-runtime.mjs +82 -0
- package/scripts/aws-macos-jit-controller.mjs +558 -0
- package/scripts/aws-macos-jit-job-controller.mjs +401 -0
- package/scripts/capture-package-release-propagation.mjs +240 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { AWS_MACOS_JIT_CONTROLLER_CONTRACT } from "./aws-macos-jit-controller-core.mjs";
|
|
7
|
+
import {
|
|
8
|
+
assertOwnership,
|
|
9
|
+
awsArgs,
|
|
10
|
+
commandResult,
|
|
11
|
+
ghJson,
|
|
12
|
+
jsonResult,
|
|
13
|
+
requireSuccess,
|
|
14
|
+
} from "./aws-macos-jit-controller-runtime.mjs";
|
|
15
|
+
import { renderMacosJitBootstrap } from "./aws-macos-jit-core.mjs";
|
|
16
|
+
|
|
17
|
+
function assertExactJobPreflight(plan, profile) {
|
|
18
|
+
const run = ghJson(
|
|
19
|
+
["api", `repos/${plan.repository}/actions/runs/${plan.github.runId}`],
|
|
20
|
+
undefined,
|
|
21
|
+
"GitHub run preflight",
|
|
22
|
+
);
|
|
23
|
+
if (
|
|
24
|
+
run.event !== plan.github.event ||
|
|
25
|
+
run.head_sha !== plan.source.sha ||
|
|
26
|
+
run.head_repository?.full_name !== plan.repository ||
|
|
27
|
+
!["queued", "in_progress"].includes(run.status)
|
|
28
|
+
) {
|
|
29
|
+
throw new Error("GitHub run is not the trusted exact-source dispatch");
|
|
30
|
+
}
|
|
31
|
+
const job = ghJson(
|
|
32
|
+
["api", `repos/${plan.repository}/actions/jobs/${plan.github.jobId}`],
|
|
33
|
+
undefined,
|
|
34
|
+
"GitHub job preflight",
|
|
35
|
+
);
|
|
36
|
+
if (job.status !== "queued") {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`GitHub job must be queued, observed ${job.status || "unknown"}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
!Array.isArray(job.labels) ||
|
|
43
|
+
!plan.runner.labels.every((label) => job.labels.includes(label))
|
|
44
|
+
) {
|
|
45
|
+
throw new Error("GitHub job labels do not match the macOS JIT job plan");
|
|
46
|
+
}
|
|
47
|
+
const host = jsonResult(
|
|
48
|
+
commandResult(
|
|
49
|
+
"aws",
|
|
50
|
+
awsArgs(plan, profile, [
|
|
51
|
+
"ec2",
|
|
52
|
+
"describe-hosts",
|
|
53
|
+
"--host-ids",
|
|
54
|
+
plan.aws.hostId,
|
|
55
|
+
"--output",
|
|
56
|
+
"json",
|
|
57
|
+
]),
|
|
58
|
+
),
|
|
59
|
+
"macOS campaign host preflight",
|
|
60
|
+
).Hosts?.[0];
|
|
61
|
+
if (
|
|
62
|
+
!host ||
|
|
63
|
+
host.State !== "available" ||
|
|
64
|
+
host.AvailabilityZone !== plan.aws.availabilityZone ||
|
|
65
|
+
host.HostProperties?.InstanceType !== plan.aws.instanceType
|
|
66
|
+
) {
|
|
67
|
+
throw new Error("macOS campaign host identity or state mismatch");
|
|
68
|
+
}
|
|
69
|
+
assertOwnership(host.Tags, plan);
|
|
70
|
+
const instance = jsonResult(
|
|
71
|
+
commandResult(
|
|
72
|
+
"aws",
|
|
73
|
+
awsArgs(plan, profile, [
|
|
74
|
+
"ec2",
|
|
75
|
+
"describe-instances",
|
|
76
|
+
"--instance-ids",
|
|
77
|
+
plan.aws.instanceId,
|
|
78
|
+
"--output",
|
|
79
|
+
"json",
|
|
80
|
+
]),
|
|
81
|
+
),
|
|
82
|
+
"macOS campaign instance preflight",
|
|
83
|
+
).Reservations?.[0]?.Instances?.[0];
|
|
84
|
+
if (
|
|
85
|
+
!instance ||
|
|
86
|
+
instance.State?.Name !== "running" ||
|
|
87
|
+
instance.Placement?.HostId !== plan.aws.hostId ||
|
|
88
|
+
instance.ImageId !== plan.aws.amiId ||
|
|
89
|
+
instance.InstanceType !== plan.aws.instanceType
|
|
90
|
+
) {
|
|
91
|
+
throw new Error("macOS campaign instance identity or state mismatch");
|
|
92
|
+
}
|
|
93
|
+
assertOwnership(instance.Tags, plan);
|
|
94
|
+
const parameters = jsonResult(
|
|
95
|
+
commandResult(
|
|
96
|
+
"aws",
|
|
97
|
+
awsArgs(plan, profile, [
|
|
98
|
+
"ssm",
|
|
99
|
+
"describe-parameters",
|
|
100
|
+
"--parameter-filters",
|
|
101
|
+
`Key=Name,Option=Equals,Values=${plan.aws.jitParameterName}`,
|
|
102
|
+
"--output",
|
|
103
|
+
"json",
|
|
104
|
+
]),
|
|
105
|
+
),
|
|
106
|
+
"macOS JIT parameter preflight",
|
|
107
|
+
);
|
|
108
|
+
if ((parameters.Parameters || []).length !== 0) {
|
|
109
|
+
throw new Error("macOS JIT parameter already exists");
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
runStatus: run.status,
|
|
113
|
+
jobStatus: job.status,
|
|
114
|
+
hostState: host.State,
|
|
115
|
+
instanceState: instance.State.Name,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function deleteRunnerRegistration(plan) {
|
|
120
|
+
const runners = ghJson(
|
|
121
|
+
["api", `repos/${plan.repository}/actions/runners?per_page=100`],
|
|
122
|
+
undefined,
|
|
123
|
+
"GitHub runner cleanup lookup",
|
|
124
|
+
).runners;
|
|
125
|
+
const runner = (runners || []).find(
|
|
126
|
+
(entry) => entry.name === plan.runner.name,
|
|
127
|
+
);
|
|
128
|
+
if (!runner) return false;
|
|
129
|
+
requireSuccess(
|
|
130
|
+
commandResult("gh", [
|
|
131
|
+
"api",
|
|
132
|
+
"--method",
|
|
133
|
+
"DELETE",
|
|
134
|
+
`repos/${plan.repository}/actions/runners/${runner.id}`,
|
|
135
|
+
]),
|
|
136
|
+
"GitHub runner cleanup",
|
|
137
|
+
);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function waitForSsmOnline(plan, profile, timeoutMs = 15 * 60 * 1000) {
|
|
142
|
+
const deadline = Date.now() + timeoutMs;
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
const response = jsonResult(
|
|
145
|
+
commandResult(
|
|
146
|
+
"aws",
|
|
147
|
+
awsArgs(plan, profile, [
|
|
148
|
+
"ssm",
|
|
149
|
+
"describe-instance-information",
|
|
150
|
+
"--filters",
|
|
151
|
+
`Key=InstanceIds,Values=${plan.aws.instanceId}`,
|
|
152
|
+
"--output",
|
|
153
|
+
"json",
|
|
154
|
+
]),
|
|
155
|
+
),
|
|
156
|
+
"macOS SSM online preflight",
|
|
157
|
+
);
|
|
158
|
+
const found = (response.InstanceInformationList || []).find(
|
|
159
|
+
(entry) =>
|
|
160
|
+
entry.InstanceId === plan.aws.instanceId &&
|
|
161
|
+
entry.PingStatus === "Online",
|
|
162
|
+
);
|
|
163
|
+
if (found) return found;
|
|
164
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
|
|
165
|
+
}
|
|
166
|
+
throw new Error("macOS campaign instance did not become SSM Online in time");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function cleanupFailedCommand(plan, profile, parameterCreated) {
|
|
170
|
+
const failures = [];
|
|
171
|
+
const attempt = (cleanup) => {
|
|
172
|
+
try {
|
|
173
|
+
cleanup();
|
|
174
|
+
} catch (error) {
|
|
175
|
+
failures.push(error.message || String(error));
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
if (parameterCreated) {
|
|
179
|
+
attempt(() =>
|
|
180
|
+
requireSuccess(
|
|
181
|
+
commandResult(
|
|
182
|
+
"aws",
|
|
183
|
+
awsArgs(plan, profile, [
|
|
184
|
+
"ssm",
|
|
185
|
+
"delete-parameter",
|
|
186
|
+
"--name",
|
|
187
|
+
plan.aws.jitParameterName,
|
|
188
|
+
]),
|
|
189
|
+
),
|
|
190
|
+
"macOS JIT parameter cleanup",
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
attempt(() => deleteRunnerRegistration(plan));
|
|
195
|
+
return failures;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createJitParameter(plan, profile, parameterInputPath) {
|
|
199
|
+
const jit = ghJson(
|
|
200
|
+
[
|
|
201
|
+
"api",
|
|
202
|
+
"--method",
|
|
203
|
+
"POST",
|
|
204
|
+
`repos/${plan.repository}/actions/runners/generate-jitconfig`,
|
|
205
|
+
"--input",
|
|
206
|
+
"-",
|
|
207
|
+
],
|
|
208
|
+
JSON.stringify({
|
|
209
|
+
name: plan.runner.name,
|
|
210
|
+
runner_group_id: 1,
|
|
211
|
+
labels: plan.runner.labels,
|
|
212
|
+
work_folder: "_work",
|
|
213
|
+
}),
|
|
214
|
+
"GitHub JIT configuration",
|
|
215
|
+
).encoded_jit_config;
|
|
216
|
+
if (!jit || typeof jit !== "string") {
|
|
217
|
+
throw new Error("GitHub JIT configuration was empty");
|
|
218
|
+
}
|
|
219
|
+
fs.writeFileSync(
|
|
220
|
+
parameterInputPath,
|
|
221
|
+
JSON.stringify({
|
|
222
|
+
Name: plan.aws.jitParameterName,
|
|
223
|
+
Description: `One-shot macOS GitHub Actions JIT config for ${plan.repository} run ${plan.github.runId}`,
|
|
224
|
+
Type: "SecureString",
|
|
225
|
+
Tier: "Advanced",
|
|
226
|
+
Value: jit,
|
|
227
|
+
Tags: [
|
|
228
|
+
{ Key: "kungfu:owner", Value: "buildchain" },
|
|
229
|
+
{ Key: "kungfu:plane", Value: "aws-us-elastic-runner-burst" },
|
|
230
|
+
{ Key: "kungfu:provider", Value: "macos-ec2-jit" },
|
|
231
|
+
{ Key: "kungfu:campaign-id", Value: plan.campaign.id },
|
|
232
|
+
{ Key: "kungfu:github-run-id", Value: plan.github.runId },
|
|
233
|
+
{ Key: "kungfu:qualification-id", Value: plan.github.qualificationId },
|
|
234
|
+
],
|
|
235
|
+
}),
|
|
236
|
+
{ mode: 0o600 },
|
|
237
|
+
);
|
|
238
|
+
requireSuccess(
|
|
239
|
+
commandResult(
|
|
240
|
+
"aws",
|
|
241
|
+
awsArgs(plan, profile, [
|
|
242
|
+
"ssm",
|
|
243
|
+
"put-parameter",
|
|
244
|
+
"--cli-input-json",
|
|
245
|
+
`file://${parameterInputPath}`,
|
|
246
|
+
"--output",
|
|
247
|
+
"json",
|
|
248
|
+
]),
|
|
249
|
+
),
|
|
250
|
+
"macOS JIT parameter creation",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sendBootstrap(plan, profile, { bootstrapPath, commandInputPath }) {
|
|
255
|
+
fs.writeFileSync(
|
|
256
|
+
bootstrapPath,
|
|
257
|
+
renderMacosJitBootstrap(
|
|
258
|
+
fs.readFileSync(
|
|
259
|
+
path.resolve(
|
|
260
|
+
"infra/aws-us-elastic-runner-burst-plane/macos-jit-bootstrap.sh",
|
|
261
|
+
),
|
|
262
|
+
"utf8",
|
|
263
|
+
),
|
|
264
|
+
{
|
|
265
|
+
region: plan.aws.region,
|
|
266
|
+
jitParameterName: plan.aws.jitParameterName,
|
|
267
|
+
evidenceBucket: plan.aws.evidenceBucket,
|
|
268
|
+
runnerLabel: plan.runner.label,
|
|
269
|
+
sourceSha: plan.source.sha,
|
|
270
|
+
githubRunId: plan.github.runId,
|
|
271
|
+
githubRunAttempt: plan.github.runAttempt,
|
|
272
|
+
amiId: plan.aws.amiId,
|
|
273
|
+
amiName: plan.aws.amiName,
|
|
274
|
+
hostId: plan.aws.hostId,
|
|
275
|
+
instanceType: plan.aws.instanceType,
|
|
276
|
+
hostAllocatedAt: plan.aws.hostAllocatedAt,
|
|
277
|
+
},
|
|
278
|
+
),
|
|
279
|
+
{ mode: 0o600 },
|
|
280
|
+
);
|
|
281
|
+
const ssm = waitForSsmOnline(plan, profile);
|
|
282
|
+
const remotePath = `/private/tmp/kungfu-macos-jit-${plan.github.runId}-${plan.github.qualificationId}.sh`;
|
|
283
|
+
const bootstrapBase64 = fs.readFileSync(bootstrapPath).toString("base64");
|
|
284
|
+
fs.writeFileSync(
|
|
285
|
+
commandInputPath,
|
|
286
|
+
JSON.stringify({
|
|
287
|
+
DocumentName: "AWS-RunShellScript",
|
|
288
|
+
InstanceIds: [plan.aws.instanceId],
|
|
289
|
+
Comment: `kungfu macOS JIT ${plan.campaign.id} ${plan.github.qualificationId}`,
|
|
290
|
+
TimeoutSeconds: 10800,
|
|
291
|
+
Parameters: {
|
|
292
|
+
commands: [
|
|
293
|
+
`printf '%s' '${bootstrapBase64}' | base64 --decode > '${remotePath}'`,
|
|
294
|
+
`chmod 700 '${remotePath}'`,
|
|
295
|
+
`sudo /bin/bash '${remotePath}'`,
|
|
296
|
+
`status=$?; rm -f '${remotePath}'; exit $status`,
|
|
297
|
+
],
|
|
298
|
+
executionTimeout: ["10800"],
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
{ mode: 0o600 },
|
|
302
|
+
);
|
|
303
|
+
const sent = jsonResult(
|
|
304
|
+
commandResult(
|
|
305
|
+
"aws",
|
|
306
|
+
awsArgs(plan, profile, [
|
|
307
|
+
"ssm",
|
|
308
|
+
"send-command",
|
|
309
|
+
"--cli-input-json",
|
|
310
|
+
`file://${commandInputPath}`,
|
|
311
|
+
"--output",
|
|
312
|
+
"json",
|
|
313
|
+
]),
|
|
314
|
+
),
|
|
315
|
+
"macOS JIT SSM command",
|
|
316
|
+
);
|
|
317
|
+
const commandId = sent.Command?.CommandId;
|
|
318
|
+
if (!/^[0-9a-f-]{36}$/.test(String(commandId || ""))) {
|
|
319
|
+
throw new Error("SSM SendCommand returned no command identity");
|
|
320
|
+
}
|
|
321
|
+
return { commandId, ssmAgentVersion: ssm.AgentVersion };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function dispatchMacosJitJob(plan, profile, files, state) {
|
|
325
|
+
createJitParameter(plan, profile, files.parameterInputPath);
|
|
326
|
+
state.parameterCreated = true;
|
|
327
|
+
requireSuccess(
|
|
328
|
+
commandResult(
|
|
329
|
+
"aws",
|
|
330
|
+
awsArgs(plan, profile, [
|
|
331
|
+
"ec2",
|
|
332
|
+
"create-tags",
|
|
333
|
+
"--resources",
|
|
334
|
+
plan.aws.instanceId,
|
|
335
|
+
"--tags",
|
|
336
|
+
`Key=kungfu:jit-parameter,Value=${plan.aws.jitParameterName}`,
|
|
337
|
+
]),
|
|
338
|
+
),
|
|
339
|
+
"macOS campaign instance JIT tag update",
|
|
340
|
+
);
|
|
341
|
+
return sendBootstrap(plan, profile, files);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function executeMacosJitJob(plan, { profile = "" } = {}) {
|
|
345
|
+
if (
|
|
346
|
+
plan?.contract !== AWS_MACOS_JIT_CONTROLLER_CONTRACT ||
|
|
347
|
+
plan.kind !== "job-run-plan"
|
|
348
|
+
) {
|
|
349
|
+
throw new Error("macOS JIT job plan contract is invalid");
|
|
350
|
+
}
|
|
351
|
+
const preflight = assertExactJobPreflight(plan, profile);
|
|
352
|
+
const tempRoot = fs.mkdtempSync(
|
|
353
|
+
path.join(os.tmpdir(), "buildchain-macos-jit-controller-"),
|
|
354
|
+
);
|
|
355
|
+
fs.chmodSync(tempRoot, 0o700);
|
|
356
|
+
const files = {
|
|
357
|
+
parameterInputPath: path.join(tempRoot, "ssm-parameter.json"),
|
|
358
|
+
bootstrapPath: path.join(tempRoot, "bootstrap.sh"),
|
|
359
|
+
commandInputPath: path.join(tempRoot, "ssm-command.json"),
|
|
360
|
+
};
|
|
361
|
+
const state = { parameterCreated: false };
|
|
362
|
+
try {
|
|
363
|
+
const sent = dispatchMacosJitJob(plan, profile, files, state);
|
|
364
|
+
return {
|
|
365
|
+
schemaVersion: 1,
|
|
366
|
+
contract: AWS_MACOS_JIT_CONTROLLER_CONTRACT,
|
|
367
|
+
kind: "job-command-result",
|
|
368
|
+
status: "command-sent",
|
|
369
|
+
campaign: plan.campaign,
|
|
370
|
+
source: plan.source,
|
|
371
|
+
github: plan.github,
|
|
372
|
+
runner: plan.runner,
|
|
373
|
+
aws: {
|
|
374
|
+
region: plan.aws.region,
|
|
375
|
+
hostId: plan.aws.hostId,
|
|
376
|
+
instanceId: plan.aws.instanceId,
|
|
377
|
+
...sent,
|
|
378
|
+
jitParameterName: plan.aws.jitParameterName,
|
|
379
|
+
},
|
|
380
|
+
preflight,
|
|
381
|
+
planDigest: plan.digest,
|
|
382
|
+
};
|
|
383
|
+
} catch (error) {
|
|
384
|
+
const failures = cleanupFailedCommand(
|
|
385
|
+
plan,
|
|
386
|
+
profile,
|
|
387
|
+
state.parameterCreated,
|
|
388
|
+
);
|
|
389
|
+
if (failures.length) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`${error.message || error}; cleanup failed: ${failures.join("; ")}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
395
|
+
} finally {
|
|
396
|
+
for (const file of Object.values(files)) {
|
|
397
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
398
|
+
}
|
|
399
|
+
fs.rmdirSync(tempRoot);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import {
|
|
10
|
+
createPackageReleasePropagationCapture,
|
|
11
|
+
normalizePackageReleasePropagationConfig,
|
|
12
|
+
} from "../packages/core/release-propagation-capture.js";
|
|
13
|
+
import { stableJson } from "../packages/core/release-propagation-common.js";
|
|
14
|
+
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const args = {};
|
|
17
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
18
|
+
const token = argv[index];
|
|
19
|
+
if (!token.startsWith("--")) throw new Error(`unexpected argument: ${token}`);
|
|
20
|
+
const key = token.slice(2);
|
|
21
|
+
const value = argv[index + 1];
|
|
22
|
+
if (!value || value.startsWith("--")) throw new Error(`${token} requires a value`);
|
|
23
|
+
args[key] = value;
|
|
24
|
+
index += 1;
|
|
25
|
+
}
|
|
26
|
+
return args;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function required(args, key) {
|
|
30
|
+
const value = String(args[key] || "").trim();
|
|
31
|
+
if (!value) throw new Error(`--${key} is required`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sha256(bytes) {
|
|
36
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function commandJson(command, args, options = {}) {
|
|
40
|
+
const text = execFileSync(command, args, { encoding: "utf8", ...options });
|
|
41
|
+
return JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertSourcePath(value) {
|
|
45
|
+
const sourcePath = String(value || "").trim();
|
|
46
|
+
if (!sourcePath || path.isAbsolute(sourcePath) || sourcePath.split("/").includes("..") || /[\r\n]/.test(sourcePath)) {
|
|
47
|
+
throw new Error("release propagation config path must be a safe repository-relative path");
|
|
48
|
+
}
|
|
49
|
+
return sourcePath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readConfigAtSource(sourceSha, configPath, cwd) {
|
|
53
|
+
const bytes = execFileSync("git", ["show", `${sourceSha}:${configPath}`], {
|
|
54
|
+
cwd,
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
});
|
|
57
|
+
const parsed = JSON.parse(bytes);
|
|
58
|
+
return { bytes, parsed, normalized: normalizePackageReleasePropagationConfig(parsed) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveTagTarget(repository, tag) {
|
|
62
|
+
let object = commandJson("gh", ["api", `repos/${repository}/git/ref/tags/${tag}`]).object;
|
|
63
|
+
for (let depth = 0; depth < 8 && object?.type === "tag"; depth += 1) {
|
|
64
|
+
object = commandJson("gh", ["api", `repos/${repository}/git/tags/${object.sha}`]).object;
|
|
65
|
+
}
|
|
66
|
+
if (object?.type !== "commit" || !/^[0-9a-f]{40}$/i.test(object.sha || "")) {
|
|
67
|
+
throw new Error(`release tag ${tag} does not resolve to one exact commit`);
|
|
68
|
+
}
|
|
69
|
+
return object.sha.toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolvePackageFact(packageName, version) {
|
|
73
|
+
const fact = commandJson("npm", [
|
|
74
|
+
"view",
|
|
75
|
+
`${packageName}@${version}`,
|
|
76
|
+
"version",
|
|
77
|
+
"dist.integrity",
|
|
78
|
+
"gitHead",
|
|
79
|
+
"--json",
|
|
80
|
+
"--registry=https://registry.npmjs.org/",
|
|
81
|
+
]);
|
|
82
|
+
return {
|
|
83
|
+
name: packageName,
|
|
84
|
+
version: String(fact.version || ""),
|
|
85
|
+
integrity: String(fact.dist?.integrity || fact["dist.integrity"] || ""),
|
|
86
|
+
gitHead: String(fact.gitHead || "").toLowerCase(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function verifyPublicReleasePassport({ repository, tag, localPath }) {
|
|
91
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "buildchain-release-passport-"));
|
|
92
|
+
try {
|
|
93
|
+
execFileSync("gh", [
|
|
94
|
+
"release", "download", tag,
|
|
95
|
+
"--repo", repository,
|
|
96
|
+
"--pattern", "buildchain.release.json",
|
|
97
|
+
"--dir", temporary,
|
|
98
|
+
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
99
|
+
const localBytes = fs.readFileSync(localPath);
|
|
100
|
+
const remotePath = path.join(temporary, "buildchain.release.json");
|
|
101
|
+
const remoteBytes = fs.readFileSync(remotePath);
|
|
102
|
+
const localDigest = sha256(localBytes);
|
|
103
|
+
const remoteDigest = sha256(remoteBytes);
|
|
104
|
+
if (localDigest !== remoteDigest) {
|
|
105
|
+
throw new Error("public release passport bytes disagree with finalized local passport");
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
url: `https://github.com/${repository}/releases/download/${tag}/buildchain.release.json`,
|
|
109
|
+
sha256: remoteDigest,
|
|
110
|
+
};
|
|
111
|
+
} finally {
|
|
112
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveBaseShas(config) {
|
|
117
|
+
return Object.fromEntries(config.targets.map((targetId) => {
|
|
118
|
+
const node = config.graph.nodes.find((entry) => entry.id === targetId);
|
|
119
|
+
if (!node?.baseRef) throw new Error(`configured propagation target ${targetId} has no baseRef`);
|
|
120
|
+
const response = commandJson("gh", ["api", `repos/${node.repository}/commits/${node.baseRef}`]);
|
|
121
|
+
const sha = String(response.sha || "").toLowerCase();
|
|
122
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) {
|
|
123
|
+
throw new Error(`configured propagation target ${targetId} baseRef did not resolve exactly`);
|
|
124
|
+
}
|
|
125
|
+
return [targetId, sha];
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeOutput(name, value) {
|
|
130
|
+
if (!process.env.GITHUB_OUTPUT) return;
|
|
131
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function capturePackageReleasePropagation({
|
|
135
|
+
config,
|
|
136
|
+
upstreamRelease,
|
|
137
|
+
expectedBaseShas,
|
|
138
|
+
outputDir,
|
|
139
|
+
configBytes = "",
|
|
140
|
+
} = {}) {
|
|
141
|
+
const captured = createPackageReleasePropagationCapture({
|
|
142
|
+
config,
|
|
143
|
+
upstreamRelease,
|
|
144
|
+
expectedBaseShas,
|
|
145
|
+
});
|
|
146
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
147
|
+
fs.writeFileSync(
|
|
148
|
+
path.join(outputDir, "config.json"),
|
|
149
|
+
configBytes || stableJson(captured.config),
|
|
150
|
+
);
|
|
151
|
+
fs.writeFileSync(path.join(outputDir, "upstream-release.json"), stableJson(captured.upstreamRelease));
|
|
152
|
+
fs.writeFileSync(path.join(outputDir, "plan.json"), stableJson(captured.plan));
|
|
153
|
+
for (const item of captured.works) {
|
|
154
|
+
const targetDir = path.join(outputDir, "work", item.propagationKey);
|
|
155
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
156
|
+
fs.writeFileSync(path.join(targetDir, "work.json"), stableJson(item.work));
|
|
157
|
+
fs.writeFileSync(path.join(targetDir, "status.json"), stableJson(item.status));
|
|
158
|
+
}
|
|
159
|
+
return captured;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function main(argv = process.argv.slice(2)) {
|
|
163
|
+
const args = parseArgs(argv);
|
|
164
|
+
const repository = required(args, "repository");
|
|
165
|
+
const channel = required(args, "channel");
|
|
166
|
+
const sourceSha = required(args, "source-sha").toLowerCase();
|
|
167
|
+
const tag = required(args, "tag");
|
|
168
|
+
const configPath = assertSourcePath(required(args, "config-path"));
|
|
169
|
+
const releasePassportPath = path.resolve(required(args, "release-passport-path"));
|
|
170
|
+
const outputDir = path.resolve(required(args, "output-dir"));
|
|
171
|
+
if (!/^[0-9a-f]{40}$/.test(sourceSha)) throw new Error("--source-sha must be an exact commit SHA");
|
|
172
|
+
if (tag !== `v${tag.replace(/^v/, "")}`) throw new Error("--tag must be an exact v-prefixed release tag");
|
|
173
|
+
const version = tag.slice(1);
|
|
174
|
+
const configSource = readConfigAtSource(sourceSha, configPath, process.cwd());
|
|
175
|
+
const sourceNode = configSource.normalized.graph.nodes.find(
|
|
176
|
+
(node) => node.id === configSource.normalized.sourceNode,
|
|
177
|
+
);
|
|
178
|
+
const packageFact = resolvePackageFact(sourceNode.package, version);
|
|
179
|
+
const tagTargetSha = resolveTagTarget(repository, tag);
|
|
180
|
+
const releasePassport = verifyPublicReleasePassport({
|
|
181
|
+
repository,
|
|
182
|
+
tag,
|
|
183
|
+
localPath: releasePassportPath,
|
|
184
|
+
});
|
|
185
|
+
const upstreamRelease = {
|
|
186
|
+
repository,
|
|
187
|
+
channel,
|
|
188
|
+
tag,
|
|
189
|
+
sourceSha,
|
|
190
|
+
tagTargetSha,
|
|
191
|
+
package: packageFact,
|
|
192
|
+
releasePassport,
|
|
193
|
+
};
|
|
194
|
+
const expectedBaseShas = resolveBaseShas(configSource.normalized);
|
|
195
|
+
const captured = capturePackageReleasePropagation({
|
|
196
|
+
config: configSource.parsed,
|
|
197
|
+
configBytes: configSource.bytes,
|
|
198
|
+
upstreamRelease,
|
|
199
|
+
expectedBaseShas,
|
|
200
|
+
outputDir,
|
|
201
|
+
});
|
|
202
|
+
const artifactName = `package-propagation-work-${version}-${sourceSha}`;
|
|
203
|
+
const workRoots = captured.works.map((item) => item.work.contentRoot);
|
|
204
|
+
writeOutput("configured", "true");
|
|
205
|
+
writeOutput("artifact-name", artifactName);
|
|
206
|
+
writeOutput("work-roots-json", JSON.stringify(workRoots));
|
|
207
|
+
process.stdout.write(stableJson({
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
contract: "kungfu-buildchain-package-release-propagation-capture-result",
|
|
210
|
+
artifactName,
|
|
211
|
+
release: {
|
|
212
|
+
repository,
|
|
213
|
+
channel,
|
|
214
|
+
tag,
|
|
215
|
+
sourceSha,
|
|
216
|
+
tagTargetSha,
|
|
217
|
+
package: packageFact,
|
|
218
|
+
releasePassport,
|
|
219
|
+
},
|
|
220
|
+
workCount: captured.works.length,
|
|
221
|
+
works: captured.works.map((item) => ({
|
|
222
|
+
target: item.target,
|
|
223
|
+
repository: item.repository,
|
|
224
|
+
propagationKey: item.propagationKey,
|
|
225
|
+
workId: item.work.workId,
|
|
226
|
+
workRoot: item.work.contentRoot,
|
|
227
|
+
nextAction: item.status.nextAction,
|
|
228
|
+
})),
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
233
|
+
if (isMain) {
|
|
234
|
+
try {
|
|
235
|
+
main();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.error(error.message);
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
}
|
|
240
|
+
}
|