@kungfu-tech/buildchain 3.0.2 → 3.0.3-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/AGENTS.md +1 -1
- package/CONTRIBUTING.md +1 -1
- package/README.md +27 -1
- package/actions/promote-buildchain-ref/README.md +90 -19
- package/actions/report-buildchain-issue/README.md +20 -2
- package/actions/run-lifecycle/README.md +5 -0
- package/actions/validate-config/README.md +19 -1
- package/bin/buildchain.mjs +56 -2
- package/bin/internal/command-registry.mjs +82 -0
- package/bin/internal/trust-release-cli.mjs +1 -0
- package/contracts/auditable-demo-media-profiles-v1.json +116 -1
- package/contracts/buildchain-v2-residuals-v1.json +146 -0
- package/contracts/evidence/auditable-demo-responsive-web-delivery-v1.json +143 -0
- package/contracts/evidence/auditable-demo-web-delivery-v1.json +7 -3
- package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/complete-transcript.txt +2 -0
- package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/public-projection.json +16 -0
- package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/scene.json +12 -0
- package/dist/site/buildchain-contract.json +188 -46
- package/dist/site/buildchain-site.json +181 -90
- package/dist/site/capability-registry.json +5 -5
- package/dist/site/cli-registry.json +860 -98
- package/dist/site/controller-registry.json +118 -5
- package/dist/site/kfd-claims.json +233 -19
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +20 -20
- package/dist/site/node-api-registry.json +374 -59
- package/dist/site/page-registry.json +156 -65
- package/dist/site/public-surface-audit.json +231 -22
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +3 -0
- package/dist/site/site-manifest.json +24 -24
- package/dist/site/workflow-registry.json +575 -79
- package/docs/MAP.md +22 -4
- package/docs/auditable-demo.md +54 -13
- package/docs/aws-us-elastic-runner-burst-plane.md +381 -0
- package/docs/cli.md +119 -12
- package/docs/consumer-issue-reporting.md +20 -2
- package/docs/dev-alpha-candidate-patrol.md +24 -10
- package/docs/github-governance-authority.md +24 -31
- package/docs/lifecycle-protocol.md +20 -2
- package/docs/migration-inventory.md +24 -5
- package/docs/observed-evidence-patrol.md +28 -12
- package/docs/ownership.md +19 -1
- package/docs/publication-artifacts.md +158 -18
- package/docs/publication-authority.md +4 -4
- package/docs/publish-transaction.md +78 -31
- package/docs/release-candidate.md +24 -0
- package/docs/release-flow.md +50 -32
- package/docs/release-governance.md +39 -32
- package/docs/release-passport.md +4 -0
- package/docs/release-propagation.md +57 -12
- package/docs/reusable-build-surface.md +218 -90
- package/docs/runtime-train-validation.md +20 -2
- package/docs/shifu-gate-profiles.md +7 -1
- package/docs/stable-candidate-patrol.md +6 -6
- package/docs/toolkit-observability.md +22 -0
- package/docs/versioning.md +46 -24
- package/docs/web-surface-deployments.md +82 -1
- package/package.json +7 -5
- package/packages/core/README.md +60 -4
- package/packages/core/artifact-signing.js +1 -0
- package/packages/core/buildchain-contract.js +9 -4
- package/packages/core/cache-evidence.js +1 -0
- package/packages/core/controller-evidence.js +2 -0
- package/packages/core/diagnostics.js +105 -30
- package/packages/core/github-governance-authority.js +25 -17
- package/packages/core/index.js +40 -0
- package/packages/core/paper.js +3226 -0
- package/packages/core/public-surface-audit.js +3 -0
- package/packages/core/publication-artifact.js +1 -6
- package/packages/core/publication-authority.js +1 -0
- package/packages/core/publication-control-plane-audit.js +28 -0
- package/packages/core/publication-reproducibility.js +947 -0
- package/packages/core/publication-sealed-bundle.js +190 -0
- package/packages/core/publish-transaction.js +147 -17
- package/packages/core/release-candidate.js +234 -0
- package/packages/core/release-passport.js +519 -228
- package/packages/core/release-propagation.js +175 -1
- package/packages/core/stable-release-gate.js +4 -1
- package/scripts/artifact-signing-delegation.mjs +268 -0
- package/scripts/audit-github-governance.mjs +32 -13
- package/scripts/audit-publication-control-plane.mjs +17 -8
- package/scripts/auditable-demo.mjs +266 -7
- package/scripts/aws-codebuild-toolchain.mjs +285 -0
- package/scripts/aws-macos-jit-core.mjs +378 -0
- package/scripts/aws-macos-jit.mjs +123 -0
- package/scripts/aws-runner-burst-core.mjs +248 -0
- package/scripts/aws-runner-burst.mjs +79 -0
- package/scripts/aws-windows-jit-core.mjs +374 -0
- package/scripts/aws-windows-jit.mjs +121 -0
- package/scripts/build-contract-core.mjs +69 -6
- package/scripts/build-standalone-binary.mjs +23 -0
- package/scripts/buildchain-contract-lock.mjs +1 -1
- package/scripts/buildchain-patrol.mjs +1 -1
- package/scripts/check-internal-architecture.mjs +69 -2
- package/scripts/check-inventory.mjs +70 -2
- package/scripts/check-javascript-syntax.mjs +31 -0
- package/scripts/check-maintainability.mjs +371 -0
- package/scripts/compiler-cache-evidence.mjs +221 -0
- package/scripts/dev-alpha-candidate-patrol.mjs +288 -43
- package/scripts/dispatch-artifact-signing-authority.mjs +7 -1
- package/scripts/finalize-native-artifact-signing-result.mjs +185 -31
- package/scripts/gate-profile-core.mjs +6 -1
- package/scripts/generate-channel-promotion-workflow.mjs +3 -12
- package/scripts/generate-release-candidate-passport.mjs +4 -0
- package/scripts/generate-site-bundle.mjs +84 -18
- package/scripts/import-artifact-signing-results.mjs +64 -5
- package/scripts/inspect-artifact-signing-requests.mjs +52 -14
- package/scripts/maintainability-metrics.mjs +339 -0
- package/scripts/npm-publish-transaction.mjs +57 -6
- package/scripts/observed-evidence.mjs +151 -33
- package/scripts/paper.mjs +645 -0
- package/scripts/promotion-channel-router.mjs +16 -4
- package/scripts/publication-reproducibility.mjs +62 -0
- package/scripts/reconcile-github-governance.mjs +8 -1
- package/scripts/release-propagation.mjs +39 -0
- package/scripts/resolve-artifact-signing-upload-route.mjs +55 -0
- package/scripts/resolve-build-contract.mjs +6 -0
- package/scripts/route-offline-runners.mjs +236 -0
- package/scripts/run-candidate-body-prefix-renderer.mjs +187 -0
- package/scripts/run-lifecycle-core.mjs +62 -1
- package/scripts/runtime-ref-core.mjs +36 -4
- package/scripts/seal-artifact-signing-requests.mjs +64 -12
- package/scripts/stable-candidate-qualification.mjs +50 -0
- package/scripts/verify-artifact-signing-results.mjs +26 -1
- package/scripts/web-surface-core.mjs +272 -86
- package/scripts/web-surface-production-decision.mjs +19 -3
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { digest } from "./aws-runner-burst-core.mjs";
|
|
2
|
+
|
|
3
|
+
export const AWS_WINDOWS_JIT_CONTRACT = "kungfu-buildchain-aws-windows-jit/v1";
|
|
4
|
+
|
|
5
|
+
export const WINDOWS_EC2_JIT = Object.freeze({
|
|
6
|
+
phase: "windows-ec2-jit",
|
|
7
|
+
region: "us-east-1",
|
|
8
|
+
repository: "kungfu-systems/kungfu",
|
|
9
|
+
stack: "kungfu-buildchain-windows-jit",
|
|
10
|
+
instanceType: "c7i.4xlarge",
|
|
11
|
+
pricePerHourUsd: 1.45,
|
|
12
|
+
maximumInstanceLifetimeMinutes: 180,
|
|
13
|
+
maxConcurrentInstances: 2,
|
|
14
|
+
maxAcceptedInstances: 6,
|
|
15
|
+
budgetLimitUsd: 40,
|
|
16
|
+
minimumSmokeJobs: 1,
|
|
17
|
+
minimumFullJobs: 3,
|
|
18
|
+
maximumCleanupLatencySeconds: 900,
|
|
19
|
+
runnerVersion: "2.336.0",
|
|
20
|
+
runnerArchive: "actions-runner-win-x64-2.336.0.zip",
|
|
21
|
+
runnerArchiveSha256:
|
|
22
|
+
"d59123a43003e357b0805b5d0f611d0bd2f65ab67d51bd070dd4e7a0f685c162",
|
|
23
|
+
powerShellVersion: "7.6.4",
|
|
24
|
+
powerShellArchive: "PowerShell-7.6.4-win-x64.msi",
|
|
25
|
+
powerShellArchiveSha256:
|
|
26
|
+
"d11942df52fd12470169797abfa4781d9480efdc81000ba4fa55a5b921ed8dd0",
|
|
27
|
+
amiSsmParameter:
|
|
28
|
+
"/aws/service/ami-windows-latest/Windows_Server-2025-English-Full-Base",
|
|
29
|
+
labelPrefix: "aws-us-ec2-windows-jit-",
|
|
30
|
+
jitParameterPrefix: "/kungfu/burst/windows/",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function exactSha(value, label) {
|
|
34
|
+
const normalized = String(value || "")
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase();
|
|
37
|
+
if (!/^[0-9a-f]{40}$/.test(normalized)) {
|
|
38
|
+
throw new Error(`${label} must be an exact 40-character Git SHA`);
|
|
39
|
+
}
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function finiteNumber(value, label) {
|
|
44
|
+
const parsed = Number(value);
|
|
45
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
46
|
+
throw new Error(`${label} must be a non-negative finite number`);
|
|
47
|
+
}
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function iso(value, label) {
|
|
52
|
+
const parsed = new Date(value || "");
|
|
53
|
+
if (!Number.isFinite(parsed.getTime())) {
|
|
54
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
55
|
+
}
|
|
56
|
+
return parsed.toISOString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function windowsJitRunnerLabel(value) {
|
|
60
|
+
const label = String(value || "")
|
|
61
|
+
.trim()
|
|
62
|
+
.toLowerCase();
|
|
63
|
+
const pattern = new RegExp(
|
|
64
|
+
`^${WINDOWS_EC2_JIT.labelPrefix}[a-z0-9][a-z0-9-]{0,31}$`,
|
|
65
|
+
);
|
|
66
|
+
if (!pattern.test(label)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`runner label must match ${WINDOWS_EC2_JIT.labelPrefix}<bounded-id>`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return label;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function windowsJitRunnerLabels(value) {
|
|
75
|
+
return ["self-hosted", "Windows", "X64", windowsJitRunnerLabel(value)];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderWindowsJitBootstrap(template, values = {}) {
|
|
79
|
+
const runnerLabel = windowsJitRunnerLabel(values.runnerLabel);
|
|
80
|
+
const jitParameterName = String(values.jitParameterName || "").trim();
|
|
81
|
+
if (
|
|
82
|
+
!jitParameterName.startsWith(WINDOWS_EC2_JIT.jitParameterPrefix) ||
|
|
83
|
+
!/^\/[A-Za-z0-9._/-]+$/.test(jitParameterName)
|
|
84
|
+
) {
|
|
85
|
+
throw new Error("jitParameterName must use the dedicated Windows prefix");
|
|
86
|
+
}
|
|
87
|
+
const bounded = {
|
|
88
|
+
REGION: String(values.region || WINDOWS_EC2_JIT.region),
|
|
89
|
+
JIT_PARAMETER_NAME: jitParameterName,
|
|
90
|
+
EVIDENCE_BUCKET: String(values.evidenceBucket || ""),
|
|
91
|
+
RUNNER_LABEL: runnerLabel,
|
|
92
|
+
SOURCE_SHA: exactSha(values.sourceSha, "sourceSha"),
|
|
93
|
+
GITHUB_RUN_ID: String(values.githubRunId || ""),
|
|
94
|
+
GITHUB_RUN_ATTEMPT: String(values.githubRunAttempt || ""),
|
|
95
|
+
AMI_ID: String(values.amiId || ""),
|
|
96
|
+
AMI_NAME: String(values.amiName || ""),
|
|
97
|
+
INSTANCE_TYPE: String(values.instanceType || WINDOWS_EC2_JIT.instanceType),
|
|
98
|
+
LAUNCHED_AT: iso(values.launchedAt, "launchedAt"),
|
|
99
|
+
};
|
|
100
|
+
const patterns = {
|
|
101
|
+
REGION: /^us-[a-z]+-\d$/,
|
|
102
|
+
EVIDENCE_BUCKET: /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/,
|
|
103
|
+
GITHUB_RUN_ID: /^\d+$/,
|
|
104
|
+
GITHUB_RUN_ATTEMPT: /^\d+$/,
|
|
105
|
+
AMI_ID: /^ami-[0-9a-f]+$/,
|
|
106
|
+
AMI_NAME: /^[A-Za-z0-9._-]+$/,
|
|
107
|
+
INSTANCE_TYPE: /^[a-z0-9.]+$/,
|
|
108
|
+
};
|
|
109
|
+
for (const [name, pattern] of Object.entries(patterns)) {
|
|
110
|
+
if (!pattern.test(bounded[name])) {
|
|
111
|
+
throw new Error(`${name.toLowerCase()} is invalid`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
let rendered = String(template || "");
|
|
115
|
+
for (const [name, value] of Object.entries(bounded)) {
|
|
116
|
+
rendered = rendered.replaceAll(`__${name}__`, value);
|
|
117
|
+
}
|
|
118
|
+
const unresolved = rendered.match(/__[A-Z0-9_]+__/g) || [];
|
|
119
|
+
if (unresolved.length) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`bootstrap has unresolved variables: ${unresolved.join(",")}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (/encoded_jit_config|github_pat_|ghp_|gho_/.test(rendered)) {
|
|
125
|
+
throw new Error("bootstrap must not contain a GitHub credential");
|
|
126
|
+
}
|
|
127
|
+
return rendered;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function windowsEc2JitPlan(overrides = {}) {
|
|
131
|
+
const config = { ...WINDOWS_EC2_JIT, ...overrides };
|
|
132
|
+
const money = (value) => Math.round(value * 100) / 100;
|
|
133
|
+
const perInstanceMaximumUsd = money(
|
|
134
|
+
(config.pricePerHourUsd * config.maximumInstanceLifetimeMinutes) / 60,
|
|
135
|
+
);
|
|
136
|
+
const maximumCommittedComputeUsd = money(
|
|
137
|
+
perInstanceMaximumUsd * config.maxAcceptedInstances,
|
|
138
|
+
);
|
|
139
|
+
const maximumRaceStopUsd = money(
|
|
140
|
+
perInstanceMaximumUsd * config.maxConcurrentInstances,
|
|
141
|
+
);
|
|
142
|
+
const maximumBoundedSpendUsd = money(
|
|
143
|
+
maximumCommittedComputeUsd + maximumRaceStopUsd,
|
|
144
|
+
);
|
|
145
|
+
if (maximumBoundedSpendUsd >= config.budgetLimitUsd) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`bounded Windows EC2 spend ${maximumBoundedSpendUsd.toFixed(2)} must remain below budget ${config.budgetLimitUsd.toFixed(2)}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const plan = {
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
contract: AWS_WINDOWS_JIT_CONTRACT,
|
|
153
|
+
kind: "phase-plan",
|
|
154
|
+
config,
|
|
155
|
+
costEnvelope: {
|
|
156
|
+
currency: "USD",
|
|
157
|
+
perInstanceMaximumUsd,
|
|
158
|
+
maximumCommittedComputeUsd,
|
|
159
|
+
maximumRaceStopUsd,
|
|
160
|
+
maximumBoundedSpendUsd,
|
|
161
|
+
assumption:
|
|
162
|
+
"The reaper charges every accepted and race instance for the full three-hour fail-closed lifetime.",
|
|
163
|
+
},
|
|
164
|
+
invariants: {
|
|
165
|
+
repositoryScopedJit: true,
|
|
166
|
+
oneJobPerRunner: true,
|
|
167
|
+
noInboundSecurityGroupRules: true,
|
|
168
|
+
imdsv2Required: true,
|
|
169
|
+
encryptedDeleteOnTerminationRoot: true,
|
|
170
|
+
jitConfigurationDeletedAfterRead: true,
|
|
171
|
+
jitConfigurationLabels: [
|
|
172
|
+
"self-hosted",
|
|
173
|
+
"Windows",
|
|
174
|
+
"X64",
|
|
175
|
+
`${config.labelPrefix}<bounded-id>`,
|
|
176
|
+
],
|
|
177
|
+
staticAwsCredentialsForbidden: true,
|
|
178
|
+
signingAndPublicationCredentialsForbidden: true,
|
|
179
|
+
zeroWarmCapacity: true,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
return { ...plan, digest: digest(plan) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createWindowsJitEvidence({
|
|
186
|
+
repository,
|
|
187
|
+
sourceSha,
|
|
188
|
+
sourceRef,
|
|
189
|
+
githubRunId,
|
|
190
|
+
githubRunAttempt,
|
|
191
|
+
githubJob,
|
|
192
|
+
runnerName,
|
|
193
|
+
runnerLabels = [],
|
|
194
|
+
instanceId,
|
|
195
|
+
instanceType,
|
|
196
|
+
amiId,
|
|
197
|
+
amiName,
|
|
198
|
+
availabilityZone,
|
|
199
|
+
launchedAt,
|
|
200
|
+
runnerStartedAt,
|
|
201
|
+
runnerExitedAt,
|
|
202
|
+
terminatedAt,
|
|
203
|
+
cleanupResult,
|
|
204
|
+
cacheMode = "off",
|
|
205
|
+
observedAt = new Date().toISOString(),
|
|
206
|
+
} = {}) {
|
|
207
|
+
if (repository !== WINDOWS_EC2_JIT.repository) {
|
|
208
|
+
throw new Error(`repository must be ${WINDOWS_EC2_JIT.repository}`);
|
|
209
|
+
}
|
|
210
|
+
if (instanceType !== WINDOWS_EC2_JIT.instanceType) {
|
|
211
|
+
throw new Error(`instanceType must be ${WINDOWS_EC2_JIT.instanceType}`);
|
|
212
|
+
}
|
|
213
|
+
if (!/^i-[0-9a-f]+$/.test(String(instanceId || ""))) {
|
|
214
|
+
throw new Error("instanceId must be an EC2 instance id");
|
|
215
|
+
}
|
|
216
|
+
if (!/^ami-[0-9a-f]+$/.test(String(amiId || ""))) {
|
|
217
|
+
throw new Error("amiId must be an EC2 AMI id");
|
|
218
|
+
}
|
|
219
|
+
const labels = [...new Set(runnerLabels.map((label) => String(label).trim()))]
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.sort();
|
|
222
|
+
const customLabel = labels.find((label) =>
|
|
223
|
+
label.startsWith(WINDOWS_EC2_JIT.labelPrefix),
|
|
224
|
+
);
|
|
225
|
+
windowsJitRunnerLabel(customLabel);
|
|
226
|
+
for (const required of ["self-hosted", "Windows", "X64"]) {
|
|
227
|
+
if (!labels.includes(required)) {
|
|
228
|
+
throw new Error(`runnerLabels must include ${required}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const evidence = {
|
|
232
|
+
schemaVersion: 1,
|
|
233
|
+
contract: AWS_WINDOWS_JIT_CONTRACT,
|
|
234
|
+
kind: "runner-evidence",
|
|
235
|
+
phase: WINDOWS_EC2_JIT.phase,
|
|
236
|
+
provider: "aws-ec2",
|
|
237
|
+
repository,
|
|
238
|
+
source: {
|
|
239
|
+
sha: exactSha(sourceSha, "sourceSha"),
|
|
240
|
+
ref: String(sourceRef || "").trim(),
|
|
241
|
+
},
|
|
242
|
+
github: {
|
|
243
|
+
runId: String(githubRunId || "").trim(),
|
|
244
|
+
runAttempt: String(githubRunAttempt || "").trim(),
|
|
245
|
+
job: String(githubJob || "").trim(),
|
|
246
|
+
},
|
|
247
|
+
runner: {
|
|
248
|
+
name: String(runnerName || "").trim(),
|
|
249
|
+
labels,
|
|
250
|
+
version: WINDOWS_EC2_JIT.runnerVersion,
|
|
251
|
+
archiveSha256: WINDOWS_EC2_JIT.runnerArchiveSha256,
|
|
252
|
+
cacheMode: String(cacheMode || "off"),
|
|
253
|
+
},
|
|
254
|
+
aws: {
|
|
255
|
+
region: WINDOWS_EC2_JIT.region,
|
|
256
|
+
instanceId: String(instanceId),
|
|
257
|
+
instanceType,
|
|
258
|
+
amiId: String(amiId),
|
|
259
|
+
amiName: String(amiName || "").trim(),
|
|
260
|
+
availabilityZone: String(availabilityZone || "").trim(),
|
|
261
|
+
},
|
|
262
|
+
lifecycle: {
|
|
263
|
+
launchedAt: iso(launchedAt, "launchedAt"),
|
|
264
|
+
runnerStartedAt: iso(runnerStartedAt, "runnerStartedAt"),
|
|
265
|
+
runnerExitedAt: iso(runnerExitedAt, "runnerExitedAt"),
|
|
266
|
+
terminatedAt: terminatedAt ? iso(terminatedAt, "terminatedAt") : null,
|
|
267
|
+
cleanupResult: String(
|
|
268
|
+
cleanupResult || "runner-exit-termination-pending",
|
|
269
|
+
).trim(),
|
|
270
|
+
},
|
|
271
|
+
observedAt: iso(observedAt, "observedAt"),
|
|
272
|
+
};
|
|
273
|
+
for (const [label, value] of Object.entries({
|
|
274
|
+
sourceRef: evidence.source.ref,
|
|
275
|
+
runId: evidence.github.runId,
|
|
276
|
+
runAttempt: evidence.github.runAttempt,
|
|
277
|
+
job: evidence.github.job,
|
|
278
|
+
runnerName: evidence.runner.name,
|
|
279
|
+
amiName: evidence.aws.amiName,
|
|
280
|
+
availabilityZone: evidence.aws.availabilityZone,
|
|
281
|
+
cleanupResult: evidence.lifecycle.cleanupResult,
|
|
282
|
+
})) {
|
|
283
|
+
if (!value) throw new Error(`${label} is required`);
|
|
284
|
+
}
|
|
285
|
+
return { ...evidence, digest: digest(evidence) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function verifyWindowsEc2JitQualification({
|
|
289
|
+
jobs = [],
|
|
290
|
+
cancellationCleanup = {},
|
|
291
|
+
timeoutCleanup = {},
|
|
292
|
+
registeredCloudRunners = [],
|
|
293
|
+
activeInstances = [],
|
|
294
|
+
disposableVolumes = [],
|
|
295
|
+
minCapacity = 0,
|
|
296
|
+
desiredCapacity = 0,
|
|
297
|
+
actualIncrementalSpendUsd,
|
|
298
|
+
observedAt = new Date().toISOString(),
|
|
299
|
+
} = {}) {
|
|
300
|
+
const plan = windowsEc2JitPlan();
|
|
301
|
+
const issues = [];
|
|
302
|
+
const accepted = jobs.filter(
|
|
303
|
+
(job) =>
|
|
304
|
+
job?.trusted === true &&
|
|
305
|
+
job?.exactSource === true &&
|
|
306
|
+
job?.status === "succeeded" &&
|
|
307
|
+
job?.oneJobJit === true,
|
|
308
|
+
);
|
|
309
|
+
const smokeJobs = accepted.filter((job) => job.kind === "smoke");
|
|
310
|
+
const fullJobs = accepted.filter((job) => job.kind === "full");
|
|
311
|
+
if (smokeJobs.length < plan.config.minimumSmokeJobs) {
|
|
312
|
+
issues.push("runner-profile-smoke-missing");
|
|
313
|
+
}
|
|
314
|
+
if (fullJobs.length < plan.config.minimumFullJobs) {
|
|
315
|
+
issues.push("insufficient-trusted-full-windows-jobs");
|
|
316
|
+
}
|
|
317
|
+
for (const [kind, cleanup] of Object.entries({
|
|
318
|
+
cancellation: cancellationCleanup,
|
|
319
|
+
timeout: timeoutCleanup,
|
|
320
|
+
})) {
|
|
321
|
+
const latency = finiteNumber(
|
|
322
|
+
cleanup?.cleanupLatencySeconds,
|
|
323
|
+
`${kind}Cleanup.cleanupLatencySeconds`,
|
|
324
|
+
);
|
|
325
|
+
if (
|
|
326
|
+
cleanup?.status !== "passed" ||
|
|
327
|
+
cleanup?.instanceTerminated !== true ||
|
|
328
|
+
cleanup?.runnerRemoved !== true ||
|
|
329
|
+
latency > plan.config.maximumCleanupLatencySeconds
|
|
330
|
+
) {
|
|
331
|
+
issues.push(`${kind}-cleanup-not-proven`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const spend = finiteNumber(
|
|
335
|
+
actualIncrementalSpendUsd,
|
|
336
|
+
"actualIncrementalSpendUsd",
|
|
337
|
+
);
|
|
338
|
+
if (spend >= plan.config.budgetLimitUsd) {
|
|
339
|
+
issues.push("actual-spend-not-below-budget");
|
|
340
|
+
}
|
|
341
|
+
if (Number(minCapacity) !== 0 || Number(desiredCapacity) !== 0) {
|
|
342
|
+
issues.push("provider-capacity-not-zero");
|
|
343
|
+
}
|
|
344
|
+
if (registeredCloudRunners.length) issues.push("registered-runners-remain");
|
|
345
|
+
if (activeInstances.length) issues.push("active-instances-remain");
|
|
346
|
+
if (disposableVolumes.length) issues.push("disposable-volumes-remain");
|
|
347
|
+
const result = {
|
|
348
|
+
schemaVersion: 1,
|
|
349
|
+
contract: AWS_WINDOWS_JIT_CONTRACT,
|
|
350
|
+
kind: "phase-verification",
|
|
351
|
+
phase: plan.config.phase,
|
|
352
|
+
status: issues.length ? "failed" : "passed",
|
|
353
|
+
qualifying: issues.length === 0,
|
|
354
|
+
metrics: {
|
|
355
|
+
acceptedJobs: accepted.length,
|
|
356
|
+
smokeJobs: smokeJobs.length,
|
|
357
|
+
fullJobs: fullJobs.length,
|
|
358
|
+
actualIncrementalSpendUsd: spend,
|
|
359
|
+
registeredCloudRunners: registeredCloudRunners.length,
|
|
360
|
+
activeInstances: activeInstances.length,
|
|
361
|
+
disposableVolumes: disposableVolumes.length,
|
|
362
|
+
minCapacity: Number(minCapacity),
|
|
363
|
+
desiredCapacity: Number(desiredCapacity),
|
|
364
|
+
},
|
|
365
|
+
cleanup: {
|
|
366
|
+
cancellation: cancellationCleanup,
|
|
367
|
+
timeout: timeoutCleanup,
|
|
368
|
+
},
|
|
369
|
+
issues,
|
|
370
|
+
planDigest: plan.digest,
|
|
371
|
+
observedAt: iso(observedAt, "observedAt"),
|
|
372
|
+
};
|
|
373
|
+
return { ...result, digest: digest(result) };
|
|
374
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
createWindowsJitEvidence,
|
|
7
|
+
renderWindowsJitBootstrap,
|
|
8
|
+
verifyWindowsEc2JitQualification,
|
|
9
|
+
windowsEc2JitPlan,
|
|
10
|
+
} from "./aws-windows-jit-core.mjs";
|
|
11
|
+
|
|
12
|
+
function arg(name, fallback = "") {
|
|
13
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
14
|
+
return index === -1 ? fallback : process.argv[index + 1] || "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJson(file) {
|
|
18
|
+
return JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeJson(file, value) {
|
|
22
|
+
const resolved = path.resolve(file);
|
|
23
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
24
|
+
fs.writeFileSync(resolved, `${JSON.stringify(value, null, 2)}\n`);
|
|
25
|
+
return resolved;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function main() {
|
|
29
|
+
const mode = process.argv[2] || "plan";
|
|
30
|
+
if (mode === "plan") {
|
|
31
|
+
const result = windowsEc2JitPlan();
|
|
32
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
if (mode === "evidence") {
|
|
36
|
+
const result = createWindowsJitEvidence({
|
|
37
|
+
repository: process.env.GITHUB_REPOSITORY,
|
|
38
|
+
sourceSha:
|
|
39
|
+
process.env.BUILDCHAIN_EXPECTED_SOURCE_SHA || process.env.GITHUB_SHA,
|
|
40
|
+
sourceRef:
|
|
41
|
+
process.env.BUILDCHAIN_EXPECTED_SOURCE_REF || process.env.GITHUB_REF,
|
|
42
|
+
githubRunId: process.env.GITHUB_RUN_ID,
|
|
43
|
+
githubRunAttempt: process.env.GITHUB_RUN_ATTEMPT,
|
|
44
|
+
githubJob: process.env.GITHUB_JOB,
|
|
45
|
+
runnerName: process.env.RUNNER_NAME,
|
|
46
|
+
runnerLabels: JSON.parse(process.env.BUILDCHAIN_RUNNER_LABELS_JSON || "[]"),
|
|
47
|
+
instanceId: process.env.AWS_EC2_INSTANCE_ID,
|
|
48
|
+
instanceType: process.env.AWS_EC2_INSTANCE_TYPE,
|
|
49
|
+
amiId: process.env.AWS_EC2_AMI_ID,
|
|
50
|
+
amiName: process.env.AWS_EC2_AMI_NAME,
|
|
51
|
+
availabilityZone: process.env.AWS_EC2_AVAILABILITY_ZONE,
|
|
52
|
+
launchedAt: process.env.AWS_EC2_LAUNCHED_AT,
|
|
53
|
+
runnerStartedAt: process.env.AWS_EC2_RUNNER_STARTED_AT,
|
|
54
|
+
runnerExitedAt:
|
|
55
|
+
process.env.AWS_EC2_RUNNER_EXITED_AT || new Date().toISOString(),
|
|
56
|
+
terminatedAt: process.env.AWS_EC2_TERMINATED_AT,
|
|
57
|
+
cleanupResult:
|
|
58
|
+
process.env.AWS_EC2_CLEANUP_RESULT || "runner-exit-termination-pending",
|
|
59
|
+
cacheMode: process.env.BUILDCHAIN_CHECKOUT_CACHE_MODE || "off",
|
|
60
|
+
});
|
|
61
|
+
const output = writeJson(
|
|
62
|
+
process.env.BUILDCHAIN_WINDOWS_JIT_EVIDENCE_PATH ||
|
|
63
|
+
".buildchain/aws-windows-jit.json",
|
|
64
|
+
result,
|
|
65
|
+
);
|
|
66
|
+
process.stdout.write(`${output}\n`);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
if (mode === "render-bootstrap") {
|
|
70
|
+
const template = arg(
|
|
71
|
+
"template",
|
|
72
|
+
"infra/aws-us-elastic-runner-burst-plane/windows-jit-bootstrap.ps1",
|
|
73
|
+
);
|
|
74
|
+
const output = arg("output");
|
|
75
|
+
if (!output) throw new Error("render-bootstrap requires --output");
|
|
76
|
+
const result = renderWindowsJitBootstrap(
|
|
77
|
+
fs.readFileSync(path.resolve(template), "utf8"),
|
|
78
|
+
{
|
|
79
|
+
region: arg("region", "us-east-1"),
|
|
80
|
+
jitParameterName: arg("jit-parameter"),
|
|
81
|
+
evidenceBucket: arg("evidence-bucket"),
|
|
82
|
+
runnerLabel: arg("runner-label"),
|
|
83
|
+
sourceSha: arg("source-sha"),
|
|
84
|
+
githubRunId: arg("github-run-id"),
|
|
85
|
+
githubRunAttempt: arg("github-run-attempt", "1"),
|
|
86
|
+
amiId: arg("ami-id"),
|
|
87
|
+
amiName: arg("ami-name"),
|
|
88
|
+
instanceType: arg("instance-type", "c7i.4xlarge"),
|
|
89
|
+
launchedAt: arg("launched-at"),
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
const resolved = path.resolve(output);
|
|
93
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
94
|
+
fs.writeFileSync(resolved, result);
|
|
95
|
+
process.stdout.write(`${resolved}\n`);
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
if (mode === "verify") {
|
|
99
|
+
const input = arg("input");
|
|
100
|
+
if (!input) throw new Error("verify requires --input");
|
|
101
|
+
const result = verifyWindowsEc2JitQualification(readJson(input));
|
|
102
|
+
const output = arg("output");
|
|
103
|
+
if (output) writeJson(output, result);
|
|
104
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
105
|
+
if (!result.qualifying) process.exitCode = 1;
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`unsupported aws-windows-jit mode: ${mode}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
process.argv[1] &&
|
|
113
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
114
|
+
) {
|
|
115
|
+
try {
|
|
116
|
+
main();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(`::error::${error.message || error}`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { macosJitRunnerLabel } from "./aws-macos-jit-core.mjs";
|
|
5
|
+
import { windowsJitRunnerLabel } from "./aws-windows-jit-core.mjs";
|
|
4
6
|
|
|
5
7
|
export const RUNNER_PRESETS = Object.freeze({
|
|
6
8
|
"github-hosted": [
|
|
@@ -61,6 +63,36 @@ export const RUNNER_PRESETS = Object.freeze({
|
|
|
61
63
|
capabilities: ["node", "native-toolchain", "product-artifacts", "rust"],
|
|
62
64
|
},
|
|
63
65
|
],
|
|
66
|
+
"aws-us-codebuild-linux": [
|
|
67
|
+
{
|
|
68
|
+
id: "linux-x64",
|
|
69
|
+
name: "Linux x64 (AWS CodeBuild burst)",
|
|
70
|
+
platform: "linux",
|
|
71
|
+
provider: "aws-codebuild",
|
|
72
|
+
runner: '["aws-codebuild-dynamic"]',
|
|
73
|
+
capabilities: ["node", "native-toolchain", "product-artifacts", "rust"],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
"aws-us-ec2-windows-jit": [
|
|
77
|
+
{
|
|
78
|
+
id: "windows-x64",
|
|
79
|
+
name: "Windows x64 (AWS EC2 one-job JIT)",
|
|
80
|
+
platform: "windows",
|
|
81
|
+
provider: "aws-ec2-windows-jit",
|
|
82
|
+
runner: '["self-hosted","Windows","X64","aws-ec2-jit-dynamic"]',
|
|
83
|
+
capabilities: ["node", "native-toolchain", "product-artifacts", "rust"],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
"aws-us-ec2-macos-jit": [
|
|
87
|
+
{
|
|
88
|
+
id: "macos-arm64",
|
|
89
|
+
name: "macOS ARM64 (AWS EC2 Dedicated Host one-job JIT)",
|
|
90
|
+
platform: "macos",
|
|
91
|
+
provider: "aws-ec2-macos-jit",
|
|
92
|
+
runner: '["self-hosted","macOS","ARM64","aws-ec2-jit-dynamic"]',
|
|
93
|
+
capabilities: ["node", "native-toolchain", "product-artifacts", "rust"],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
64
96
|
});
|
|
65
97
|
|
|
66
98
|
export const LINUX_CONTAINER_PRESETS = Object.freeze({
|
|
@@ -759,6 +791,8 @@ function normalizePlatform(platform, index) {
|
|
|
759
791
|
}
|
|
760
792
|
const normalized = { id, name, runner };
|
|
761
793
|
if (platform?.platform !== undefined) normalized.platform = String(platform.platform || "").trim();
|
|
794
|
+
if (platform?.provider !== undefined) normalized.provider = String(platform.provider || "").trim();
|
|
795
|
+
if (platform?.project !== undefined) normalized.project = String(platform.project || "").trim();
|
|
762
796
|
normalized.capabilities = capabilities.sort();
|
|
763
797
|
if (platform?.required === false) normalized.required = false;
|
|
764
798
|
return normalized;
|
|
@@ -767,6 +801,9 @@ function normalizePlatform(platform, index) {
|
|
|
767
801
|
export function resolveRunnerMatrix({
|
|
768
802
|
runnerPreset = "github-hosted",
|
|
769
803
|
platformsJson = "",
|
|
804
|
+
awsCodeBuildProject = "",
|
|
805
|
+
awsEc2WindowsRunnerLabel = "",
|
|
806
|
+
awsEc2MacosRunnerLabel = "",
|
|
770
807
|
linuxContainerPreset = "",
|
|
771
808
|
linuxContainerImage = "",
|
|
772
809
|
} = {}) {
|
|
@@ -812,18 +849,44 @@ export function resolveRunnerMatrix({
|
|
|
812
849
|
if (!platforms) {
|
|
813
850
|
throw new Error(`unsupported runner-preset: ${preset}`);
|
|
814
851
|
}
|
|
852
|
+
let resolvedPlatforms = platforms;
|
|
853
|
+
if (preset === "aws-us-codebuild-linux") {
|
|
854
|
+
const project = String(awsCodeBuildProject || "").trim();
|
|
855
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,149}$/.test(project)) {
|
|
856
|
+
throw new Error(
|
|
857
|
+
"runner-preset=aws-us-codebuild-linux requires a valid aws-codebuild-project",
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
resolvedPlatforms = platforms.map((platform) => ({ ...platform, project }));
|
|
861
|
+
}
|
|
862
|
+
if (preset === "aws-us-ec2-windows-jit") {
|
|
863
|
+
const runnerLabel = windowsJitRunnerLabel(awsEc2WindowsRunnerLabel);
|
|
864
|
+
resolvedPlatforms = platforms.map((platform) => ({
|
|
865
|
+
...platform,
|
|
866
|
+
runnerLabel,
|
|
867
|
+
runner: JSON.stringify(["self-hosted", "Windows", "X64", runnerLabel]),
|
|
868
|
+
}));
|
|
869
|
+
}
|
|
870
|
+
if (preset === "aws-us-ec2-macos-jit") {
|
|
871
|
+
const runnerLabel = macosJitRunnerLabel(awsEc2MacosRunnerLabel);
|
|
872
|
+
resolvedPlatforms = platforms.map((platform) => ({
|
|
873
|
+
...platform,
|
|
874
|
+
runnerLabel,
|
|
875
|
+
runner: JSON.stringify(["self-hosted", "macOS", "ARM64", runnerLabel]),
|
|
876
|
+
}));
|
|
877
|
+
}
|
|
815
878
|
const containerPlatforms = linuxContainer.enabled
|
|
816
|
-
?
|
|
879
|
+
? resolvedPlatforms.filter(platformIsLinux)
|
|
817
880
|
: [];
|
|
818
881
|
const nativePlatforms = linuxContainer.enabled
|
|
819
|
-
?
|
|
820
|
-
:
|
|
882
|
+
? resolvedPlatforms.filter((platform) => !platformIsLinux(platform))
|
|
883
|
+
: resolvedPlatforms;
|
|
821
884
|
return {
|
|
822
885
|
source: "runner-preset",
|
|
823
886
|
runnerPreset: preset,
|
|
824
|
-
platforms,
|
|
825
|
-
platformsJson: JSON.stringify(
|
|
826
|
-
platformCount:
|
|
887
|
+
platforms: resolvedPlatforms,
|
|
888
|
+
platformsJson: JSON.stringify(resolvedPlatforms),
|
|
889
|
+
platformCount: resolvedPlatforms.length,
|
|
827
890
|
nativePlatforms,
|
|
828
891
|
nativePlatformsJson: JSON.stringify(nativePlatforms),
|
|
829
892
|
nativePlatformCount: nativePlatforms.length,
|
|
@@ -149,9 +149,29 @@ function packageVersion(cwd) {
|
|
|
149
149
|
return packageJson.version;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
function sourceSha(cwd) {
|
|
153
|
+
const result = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
154
|
+
cwd,
|
|
155
|
+
encoding: "utf8",
|
|
156
|
+
});
|
|
157
|
+
const value = String(result.stdout || "").trim();
|
|
158
|
+
if (result.status !== 0 || !/^[0-9a-f]{40}$/i.test(value)) {
|
|
159
|
+
throw new Error("standalone binary requires an exact Buildchain source SHA");
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
152
164
|
function bundleCli({ cwd, tempDir, version, logger }) {
|
|
153
165
|
const outDir = path.join(tempDir, "bundle");
|
|
154
166
|
const configPath = path.join(tempDir, "tsup.config.mjs");
|
|
167
|
+
const embeddedContractWorld = fs.readFileSync(
|
|
168
|
+
path.join(cwd, "dist", "site", "buildchain-contract.json"),
|
|
169
|
+
"utf8",
|
|
170
|
+
);
|
|
171
|
+
const embeddedLicenseText = fs.readFileSync(
|
|
172
|
+
path.join(cwd, "LICENSE"),
|
|
173
|
+
"utf8",
|
|
174
|
+
);
|
|
155
175
|
fs.writeFileSync(configPath, `export default {
|
|
156
176
|
entry: {
|
|
157
177
|
buildchain: ${JSON.stringify(path.join(cwd, "bin", "buildchain.mjs"))},
|
|
@@ -169,6 +189,9 @@ function bundleCli({ cwd, tempDir, version, logger }) {
|
|
|
169
189
|
noExternal: ["smol-toml", /^@kungfu-tech\\/kfd(?:\\/|$)/],
|
|
170
190
|
define: {
|
|
171
191
|
"process.env.BUILDCHAIN_EMBEDDED_PACKAGE_VERSION": ${JSON.stringify(JSON.stringify(version || packageVersion(cwd)))},
|
|
192
|
+
"process.env.BUILDCHAIN_EMBEDDED_SOURCE_SHA": ${JSON.stringify(JSON.stringify(sourceSha(cwd)))},
|
|
193
|
+
"process.env.BUILDCHAIN_EMBEDDED_CONTRACT_WORLD": ${JSON.stringify(JSON.stringify(embeddedContractWorld))},
|
|
194
|
+
"process.env.BUILDCHAIN_EMBEDDED_LICENSE_TEXT": ${JSON.stringify(JSON.stringify(embeddedLicenseText))},
|
|
172
195
|
"process.env.BUILDCHAIN_EMBEDDED_ENTRYPOINT": ${JSON.stringify(JSON.stringify("1"))},
|
|
173
196
|
},
|
|
174
197
|
};
|
|
@@ -131,7 +131,7 @@ export function writeBuildchainContractLock({
|
|
|
131
131
|
output = env("BUILDCHAIN_CONTRACT_LOCK_PATH", BUILDCHAIN_CONTRACT_LOCK_PATH),
|
|
132
132
|
currentContractPath = env("BUILDCHAIN_CONTRACT_CURRENT_PATH", "dist/site/buildchain-contract.json"),
|
|
133
133
|
runtimeRoot = env("BUILDCHAIN_RUNTIME_ROOT", process.cwd()),
|
|
134
|
-
buildchainRef = env("BUILDCHAIN_RUNTIME_REF", "
|
|
134
|
+
buildchainRef = env("BUILDCHAIN_RUNTIME_REF", "v3"),
|
|
135
135
|
resolvedSha = env("BUILDCHAIN_RUNTIME_SHA"),
|
|
136
136
|
compatibilityPolicy = env("BUILDCHAIN_CONTRACT_COMPATIBILITY_POLICY", "major-compatible"),
|
|
137
137
|
acceptedAt = env("BUILDCHAIN_CONTRACT_ACCEPTED_AT") || new Date().toISOString(),
|
|
@@ -49,7 +49,7 @@ function normalizeRepository(value) {
|
|
|
49
49
|
function normalizeTargetBranch(value) {
|
|
50
50
|
const branch = String(value || process.env.GITHUB_REF_NAME || DEFAULT_TARGET_BRANCH).replace(/^refs\/heads\//, "");
|
|
51
51
|
if (!/^dev\/v\d+\/v\d+\.\d+$/.test(branch)) {
|
|
52
|
-
throw new Error(`target-branch must be a semver dev branch such as dev/
|
|
52
|
+
throw new Error(`target-branch must be a semver dev branch such as dev/v3/v3.N, got: ${branch || "<empty>"}`);
|
|
53
53
|
}
|
|
54
54
|
return branch;
|
|
55
55
|
}
|