@kungfu-tech/buildchain 3.0.5-alpha.3 → 3.0.5-alpha.5
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 +5 -5
- package/dist/site/buildchain-site.json +12 -12
- package/dist/site/kfd-claims.json +2 -2
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +2 -2
- package/dist/site/node-api-registry.json +9 -9
- package/dist/site/page-registry.json +6 -6
- package/dist/site/public-surface-audit.json +1 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +6 -6
- package/docs/aws-us-elastic-runner-burst-plane.md +18 -9
- package/docs/node-api-reference.md +21 -21
- package/docs/release-governance.md +32 -0
- package/package.json +1 -1
- package/packages/core/public-surface-audit.js +4 -35
- package/packages/core/workflow-call-contract.js +308 -0
- package/packages/core/workflow-yaml-contract.js +272 -0
- package/scripts/auditable-demo-renditions.mjs +1 -1
- package/scripts/auditable-demo.mjs +13 -17
- package/scripts/aws-windows-jit-campaign-core.mjs +48 -9
- package/scripts/aws-windows-jit-campaign.mjs +23 -5
- package/scripts/aws-windows-jit-core.mjs +3 -3
- package/scripts/capture-package-release-propagation.mjs +24 -1
- package/scripts/workflow-call-contract.mjs +133 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { parseWorkflowDocument } from "./workflow-yaml-contract.js";
|
|
3
|
+
|
|
4
|
+
export const WORKFLOW_CALL_CONTRACT =
|
|
5
|
+
"kungfu-buildchain-workflow-call-contract/v1";
|
|
6
|
+
export const WORKFLOW_CALL_RECEIPT =
|
|
7
|
+
"kungfu-buildchain-workflow-call-receipt/v1";
|
|
8
|
+
|
|
9
|
+
function stableJson(value) {
|
|
10
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
11
|
+
if (value && typeof value === "object") {
|
|
12
|
+
return `{${Object.keys(value)
|
|
13
|
+
.sort()
|
|
14
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
15
|
+
.join(",")}}`;
|
|
16
|
+
}
|
|
17
|
+
return JSON.stringify(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sha256(value) {
|
|
21
|
+
return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function exactSha(value) {
|
|
25
|
+
return /^[0-9a-f]{40}$/.test(String(value || "").toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function coordinate(uses) {
|
|
29
|
+
const match = String(uses || "").match(
|
|
30
|
+
/^([^/]+\/[^/]+)\/(\.github\/workflows\/[^@]+)@([^@]+)$/,
|
|
31
|
+
);
|
|
32
|
+
return match ? { repository: match[1], path: match[2], ref: match[3] } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function permissionLevel(value) {
|
|
36
|
+
if (value === "write" || value === "write-all") return 2;
|
|
37
|
+
if (value === "read" || value === "read-all") return 1;
|
|
38
|
+
if (value === "none" || value === "{}") return 0;
|
|
39
|
+
return -1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function callerPermission(permissions, name) {
|
|
43
|
+
return permissions[name] || permissions["*"] || "none";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validatePermissions(caller, callee) {
|
|
47
|
+
const failures = [];
|
|
48
|
+
for (const [name, required] of Object.entries(callee)) {
|
|
49
|
+
if (name === "*") continue;
|
|
50
|
+
const available = callerPermission(caller, name);
|
|
51
|
+
if (
|
|
52
|
+
permissionLevel(required) < 0 ||
|
|
53
|
+
permissionLevel(available) < permissionLevel(required)
|
|
54
|
+
) {
|
|
55
|
+
failures.push({
|
|
56
|
+
code: "permission-drift",
|
|
57
|
+
message: `caller permission ${name}=${available} does not satisfy callee ${required}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return failures;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateInputs(job, reusable) {
|
|
65
|
+
const failures = [];
|
|
66
|
+
const declared = new Map(reusable.inputs.map((entry) => [entry.name, entry]));
|
|
67
|
+
for (const [name, supplied] of Object.entries(job.with)) {
|
|
68
|
+
const input = declared.get(name);
|
|
69
|
+
if (!input) {
|
|
70
|
+
failures.push({
|
|
71
|
+
code: "undeclared-input",
|
|
72
|
+
message: `caller supplies undeclared input: ${name}`,
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (supplied.kind !== "expression" && supplied.kind !== input.type) {
|
|
77
|
+
failures.push({
|
|
78
|
+
code: "input-type-drift",
|
|
79
|
+
message: `caller input ${name} is ${supplied.kind}, callee requires ${input.type}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const input of reusable.inputs.filter((entry) => entry.required)) {
|
|
84
|
+
if (!(input.name in job.with)) {
|
|
85
|
+
failures.push({
|
|
86
|
+
code: "missing-required-input",
|
|
87
|
+
message: `caller omits required input: ${input.name}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return failures;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateSecrets(job, reusable) {
|
|
95
|
+
if (job.secrets === "inherit") {
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
code: "secret-inheritance-not-exact",
|
|
99
|
+
message:
|
|
100
|
+
"secrets: inherit cannot prove an exact reusable-workflow secret contract",
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
const failures = [];
|
|
105
|
+
const declared = new Map(
|
|
106
|
+
reusable.secrets.map((entry) => [entry.name, entry]),
|
|
107
|
+
);
|
|
108
|
+
for (const name of Object.keys(job.secrets)) {
|
|
109
|
+
if (!declared.has(name)) {
|
|
110
|
+
failures.push({
|
|
111
|
+
code: "undeclared-secret",
|
|
112
|
+
message: `caller supplies undeclared secret: ${name}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const secret of reusable.secrets.filter((entry) => entry.required)) {
|
|
117
|
+
if (!(secret.name in job.secrets)) {
|
|
118
|
+
failures.push({
|
|
119
|
+
code: "missing-required-secret",
|
|
120
|
+
message: `caller omits required secret: ${secret.name}`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return failures;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function validateEvents(actual, trusted) {
|
|
128
|
+
const trustedSet = new Set(trusted);
|
|
129
|
+
return actual
|
|
130
|
+
.filter((event) => !trustedSet.has(event))
|
|
131
|
+
.map((event) => ({
|
|
132
|
+
code: "untrusted-event-drift",
|
|
133
|
+
message: `caller admits untrusted event class: ${event}`,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function interfaceProjection(reusable) {
|
|
138
|
+
return {
|
|
139
|
+
inputs: reusable.inputs,
|
|
140
|
+
secrets: reusable.secrets,
|
|
141
|
+
outputs: reusable.outputs,
|
|
142
|
+
permissions: reusable.permissions,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateCoordinate(job, { repository, workflowPath, sha }) {
|
|
147
|
+
const failures = [];
|
|
148
|
+
const pinned = coordinate(job?.uses);
|
|
149
|
+
if (!pinned) {
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
code: "callee-coordinate-invalid",
|
|
153
|
+
message:
|
|
154
|
+
"caller uses must name owner/repo/.github/workflows/file.yml@ref",
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
if (!exactSha(pinned.ref)) {
|
|
159
|
+
failures.push({
|
|
160
|
+
code: "floating-callee-ref",
|
|
161
|
+
message: `callee ref is not an exact commit SHA: ${pinned.ref}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (pinned.repository !== repository || pinned.path !== workflowPath) {
|
|
165
|
+
failures.push({
|
|
166
|
+
code: "callee-coordinate-drift",
|
|
167
|
+
message: `caller uses ${pinned.repository}/${pinned.path}, expected ${repository}/${workflowPath}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (pinned.ref.toLowerCase() !== String(sha || "").toLowerCase()) {
|
|
171
|
+
failures.push({
|
|
172
|
+
code: "stale-pinned-ref",
|
|
173
|
+
message: `caller pin ${pinned.ref} does not match checked callee ${sha}`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return failures;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function evaluateWorkflowCallContract({
|
|
180
|
+
callerText,
|
|
181
|
+
calleeText,
|
|
182
|
+
callerRepository,
|
|
183
|
+
callerWorkflowPath,
|
|
184
|
+
callerSha,
|
|
185
|
+
callerTree,
|
|
186
|
+
callerSourceState = "clean",
|
|
187
|
+
calleeRepository,
|
|
188
|
+
calleeWorkflowPath,
|
|
189
|
+
calleeSha,
|
|
190
|
+
jobId,
|
|
191
|
+
trustedEventClasses = [],
|
|
192
|
+
expectedContractRoot = "",
|
|
193
|
+
} = {}) {
|
|
194
|
+
const caller = parseWorkflowDocument(callerText);
|
|
195
|
+
const callee = parseWorkflowDocument(calleeText);
|
|
196
|
+
const job = caller.callJobs.find((entry) => entry.id === jobId);
|
|
197
|
+
const failures = [];
|
|
198
|
+
if (!job)
|
|
199
|
+
failures.push({
|
|
200
|
+
code: "call-job-missing",
|
|
201
|
+
message: `reusable-workflow call job is missing: ${jobId}`,
|
|
202
|
+
});
|
|
203
|
+
if (!callee.interface.reusable)
|
|
204
|
+
failures.push({
|
|
205
|
+
code: "callee-not-reusable",
|
|
206
|
+
message: "callee does not declare on.workflow_call",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
failures.push(
|
|
210
|
+
...validateCoordinate(job, {
|
|
211
|
+
repository: calleeRepository,
|
|
212
|
+
workflowPath: calleeWorkflowPath,
|
|
213
|
+
sha: calleeSha,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
if (!exactSha(calleeSha))
|
|
217
|
+
failures.push({
|
|
218
|
+
code: "callee-sha-invalid",
|
|
219
|
+
message: "callee SHA must be an exact 40-character commit",
|
|
220
|
+
});
|
|
221
|
+
if (!exactSha(callerSha) || !exactSha(callerTree))
|
|
222
|
+
failures.push({
|
|
223
|
+
code: "caller-source-invalid",
|
|
224
|
+
message: "caller SHA and tree must be exact 40-character Git object ids",
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (job && callee.interface.reusable) {
|
|
228
|
+
failures.push(...validateInputs(job, callee.interface));
|
|
229
|
+
failures.push(...validateSecrets(job, callee.interface));
|
|
230
|
+
failures.push(
|
|
231
|
+
...validatePermissions(job.permissions, callee.interface.permissions),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
failures.push(...validateEvents(caller.triggers, trustedEventClasses));
|
|
235
|
+
|
|
236
|
+
const contract = {
|
|
237
|
+
schema: WORKFLOW_CALL_CONTRACT,
|
|
238
|
+
caller: {
|
|
239
|
+
repository: callerRepository,
|
|
240
|
+
workflowPath: callerWorkflowPath,
|
|
241
|
+
workflowDigest: sha256(String(callerText || "")),
|
|
242
|
+
jobId,
|
|
243
|
+
eventClasses: caller.triggers,
|
|
244
|
+
uses: job?.uses || "",
|
|
245
|
+
inputs: job?.with || {},
|
|
246
|
+
secretNames:
|
|
247
|
+
job?.secrets === "inherit"
|
|
248
|
+
? ["*"]
|
|
249
|
+
: Object.keys(job?.secrets || {}).sort(),
|
|
250
|
+
permissions: job?.permissions || {},
|
|
251
|
+
},
|
|
252
|
+
callee: {
|
|
253
|
+
repository: calleeRepository,
|
|
254
|
+
workflowPath: calleeWorkflowPath,
|
|
255
|
+
sha: String(calleeSha || "").toLowerCase(),
|
|
256
|
+
workflowDigest: sha256(String(calleeText || "")),
|
|
257
|
+
interface: interfaceProjection(callee.interface),
|
|
258
|
+
},
|
|
259
|
+
trustedEventClasses: [...new Set(trustedEventClasses)].sort(),
|
|
260
|
+
};
|
|
261
|
+
const contractRoot = sha256(stableJson(contract));
|
|
262
|
+
if (expectedContractRoot && expectedContractRoot !== contractRoot) {
|
|
263
|
+
failures.push({
|
|
264
|
+
code: "contract-root-mismatch",
|
|
265
|
+
message: `workflow call contract root ${contractRoot} does not match accepted ${expectedContractRoot}`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
const receipt = {
|
|
269
|
+
schema: WORKFLOW_CALL_RECEIPT,
|
|
270
|
+
contractRoot,
|
|
271
|
+
caller: {
|
|
272
|
+
repository: callerRepository,
|
|
273
|
+
sha: String(callerSha || "").toLowerCase(),
|
|
274
|
+
tree: String(callerTree || "").toLowerCase(),
|
|
275
|
+
sourceState: callerSourceState,
|
|
276
|
+
workflowPath: callerWorkflowPath,
|
|
277
|
+
workflowDigest: contract.caller.workflowDigest,
|
|
278
|
+
},
|
|
279
|
+
callee: {
|
|
280
|
+
repository: calleeRepository,
|
|
281
|
+
sha: String(calleeSha || "").toLowerCase(),
|
|
282
|
+
workflowPath: calleeWorkflowPath,
|
|
283
|
+
workflowDigest: contract.callee.workflowDigest,
|
|
284
|
+
},
|
|
285
|
+
eventClasses: caller.triggers,
|
|
286
|
+
};
|
|
287
|
+
return {
|
|
288
|
+
schema: "kungfu-buildchain-workflow-call-check/v1",
|
|
289
|
+
ok: failures.length === 0,
|
|
290
|
+
status: failures.length === 0 ? "passed" : "failed",
|
|
291
|
+
failures,
|
|
292
|
+
contract,
|
|
293
|
+
contractRoot,
|
|
294
|
+
receipt,
|
|
295
|
+
receiptRoot: sha256(stableJson(receipt)),
|
|
296
|
+
receiptReusable: failures.length === 0 && callerSourceState === "clean",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function assertWorkflowCallContract(options) {
|
|
301
|
+
const report = evaluateWorkflowCallContract(options);
|
|
302
|
+
if (!report.ok) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Reusable workflow call contract failed:\n${report.failures.map((entry) => `${entry.code}: ${entry.message}`).join("\n")}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return report;
|
|
308
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
function indentation(line) {
|
|
2
|
+
return line.match(/^(\s*)/)?.[1].length || 0;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function stripComment(value) {
|
|
6
|
+
let quote = "";
|
|
7
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
8
|
+
const char = value[index];
|
|
9
|
+
if ((char === '"' || char === "'") && value[index - 1] !== "\\") {
|
|
10
|
+
quote = quote === char ? "" : quote || char;
|
|
11
|
+
}
|
|
12
|
+
if (
|
|
13
|
+
char === "#" &&
|
|
14
|
+
!quote &&
|
|
15
|
+
(index === 0 || /\s/.test(value[index - 1]))
|
|
16
|
+
) {
|
|
17
|
+
return value.slice(0, index).trimEnd();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return value.trimEnd();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function unquote(value) {
|
|
24
|
+
const trimmed = stripComment(String(value || "").trim());
|
|
25
|
+
if (
|
|
26
|
+
trimmed.length >= 2 &&
|
|
27
|
+
trimmed[0] === trimmed.at(-1) &&
|
|
28
|
+
['"', "'"].includes(trimmed[0])
|
|
29
|
+
) {
|
|
30
|
+
return trimmed.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
return trimmed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function blockValue(lines, entry) {
|
|
36
|
+
if (!/^[>|][+-]?$/.test(entry.value)) return entry.value;
|
|
37
|
+
const values = [];
|
|
38
|
+
for (let index = entry.line + 1; index < lines.length; index += 1) {
|
|
39
|
+
const line = lines[index];
|
|
40
|
+
if (line.trim() && indentation(line) <= entry.indent) break;
|
|
41
|
+
values.push(line);
|
|
42
|
+
}
|
|
43
|
+
const content = values.filter((line) => line.trim());
|
|
44
|
+
const contentIndent = content.length
|
|
45
|
+
? Math.min(...content.map(indentation))
|
|
46
|
+
: entry.indent + 2;
|
|
47
|
+
return values
|
|
48
|
+
.map((line) => line.slice(Math.min(contentIndent, line.length)))
|
|
49
|
+
.join("\n")
|
|
50
|
+
.trimEnd();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function directEntries(lines, parentLine) {
|
|
54
|
+
const parentIndent = indentation(lines[parentLine]);
|
|
55
|
+
let childIndent = null;
|
|
56
|
+
for (let index = parentLine + 1; index < lines.length; index += 1) {
|
|
57
|
+
const line = lines[index];
|
|
58
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
59
|
+
const indent = indentation(line);
|
|
60
|
+
if (indent <= parentIndent) break;
|
|
61
|
+
childIndent = childIndent === null ? indent : Math.min(childIndent, indent);
|
|
62
|
+
}
|
|
63
|
+
if (childIndent === null) return [];
|
|
64
|
+
const entries = [];
|
|
65
|
+
for (let index = parentLine + 1; index < lines.length; index += 1) {
|
|
66
|
+
const line = lines[index];
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
const indent = indentation(line);
|
|
69
|
+
if (trimmed && indent <= parentIndent) break;
|
|
70
|
+
if (!trimmed || trimmed.startsWith("#") || indent !== childIndent)
|
|
71
|
+
continue;
|
|
72
|
+
const match = line.match(/^\s*([A-Za-z0-9_.-]+):(?:\s*(.*))?$/);
|
|
73
|
+
if (match) {
|
|
74
|
+
entries.push({
|
|
75
|
+
key: match[1],
|
|
76
|
+
value: stripComment(match[2] || ""),
|
|
77
|
+
line: index,
|
|
78
|
+
indent,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return entries;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function compareCodeUnits(left, right) {
|
|
86
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function topLevelEntry(lines, key) {
|
|
90
|
+
const pattern = new RegExp(
|
|
91
|
+
`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:(?:\\s*(.*))?$`,
|
|
92
|
+
);
|
|
93
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
94
|
+
const match = lines[index].match(pattern);
|
|
95
|
+
if (match)
|
|
96
|
+
return {
|
|
97
|
+
key,
|
|
98
|
+
value: stripComment(match[1] || ""),
|
|
99
|
+
line: index,
|
|
100
|
+
indent: 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function childEntry(lines, parent, key) {
|
|
107
|
+
return (
|
|
108
|
+
directEntries(lines, parent.line).find((entry) => entry.key === key) || null
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function scalar(value) {
|
|
113
|
+
const raw = stripComment(String(value || "").trim());
|
|
114
|
+
if (raw.length >= 2 && raw[0] === raw.at(-1) && ['"', "'"].includes(raw[0])) {
|
|
115
|
+
return { kind: "string", value: raw.slice(1, -1) };
|
|
116
|
+
}
|
|
117
|
+
const text = unquote(raw);
|
|
118
|
+
if (/^\$\{\{[\s\S]*\}\}$/.test(text))
|
|
119
|
+
return { kind: "expression", value: text };
|
|
120
|
+
if (/^(true|false)$/i.test(text))
|
|
121
|
+
return { kind: "boolean", value: text.toLowerCase() === "true" };
|
|
122
|
+
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(text))
|
|
123
|
+
return { kind: "number", value: Number(text) };
|
|
124
|
+
if (/^(null|~)$/i.test(text)) return { kind: "null", value: null };
|
|
125
|
+
return { kind: "string", value: text };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parsePermissions(lines, entry) {
|
|
129
|
+
if (!entry) return {};
|
|
130
|
+
if (entry.value) return { "*": unquote(entry.value) };
|
|
131
|
+
return Object.fromEntries(
|
|
132
|
+
directEntries(lines, entry.line).map((item) => [
|
|
133
|
+
item.key,
|
|
134
|
+
unquote(item.value),
|
|
135
|
+
]),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseTriggers(lines) {
|
|
140
|
+
const on = topLevelEntry(lines, "on");
|
|
141
|
+
if (!on) return [];
|
|
142
|
+
if (on.value) {
|
|
143
|
+
const inline = unquote(on.value);
|
|
144
|
+
if (inline.startsWith("[") && inline.endsWith("]")) {
|
|
145
|
+
return inline
|
|
146
|
+
.slice(1, -1)
|
|
147
|
+
.split(",")
|
|
148
|
+
.map((value) => unquote(value.trim()))
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.sort();
|
|
151
|
+
}
|
|
152
|
+
return inline ? [inline] : [];
|
|
153
|
+
}
|
|
154
|
+
const events = [];
|
|
155
|
+
for (const event of directEntries(lines, on.line)) {
|
|
156
|
+
const types = childEntry(lines, event, "types");
|
|
157
|
+
const rawTypes = types ? unquote(types.value) : "";
|
|
158
|
+
if (rawTypes.startsWith("[") && rawTypes.endsWith("]")) {
|
|
159
|
+
for (const type of rawTypes
|
|
160
|
+
.slice(1, -1)
|
|
161
|
+
.split(",")
|
|
162
|
+
.map((value) => unquote(value.trim()))
|
|
163
|
+
.filter(Boolean)) {
|
|
164
|
+
events.push(`${event.key}:${type}`);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
events.push(event.key);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...new Set(events)].sort();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function definition(lines, entry) {
|
|
174
|
+
const properties = Object.fromEntries(
|
|
175
|
+
directEntries(lines, entry.line).map((item) => [
|
|
176
|
+
item.key,
|
|
177
|
+
blockValue(lines, item),
|
|
178
|
+
]),
|
|
179
|
+
);
|
|
180
|
+
return {
|
|
181
|
+
name: entry.key,
|
|
182
|
+
type: unquote(properties.type || "string"),
|
|
183
|
+
required: scalar(properties.required || "false").value === true,
|
|
184
|
+
default:
|
|
185
|
+
properties.default === undefined ? null : scalar(properties.default),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function parseReusableWorkflowInterface(text) {
|
|
190
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
191
|
+
const on = topLevelEntry(lines, "on");
|
|
192
|
+
const workflowCall = on && childEntry(lines, on, "workflow_call");
|
|
193
|
+
if (!workflowCall) {
|
|
194
|
+
return {
|
|
195
|
+
reusable: false,
|
|
196
|
+
inputs: [],
|
|
197
|
+
secrets: [],
|
|
198
|
+
outputs: [],
|
|
199
|
+
permissions: parsePermissions(lines, topLevelEntry(lines, "permissions")),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const inputs = childEntry(lines, workflowCall, "inputs");
|
|
203
|
+
const secrets = childEntry(lines, workflowCall, "secrets");
|
|
204
|
+
const outputs = childEntry(lines, workflowCall, "outputs");
|
|
205
|
+
return {
|
|
206
|
+
reusable: true,
|
|
207
|
+
inputs: inputs
|
|
208
|
+
? directEntries(lines, inputs.line)
|
|
209
|
+
.map((entry) => definition(lines, entry))
|
|
210
|
+
.sort((a, b) => compareCodeUnits(a.name, b.name))
|
|
211
|
+
: [],
|
|
212
|
+
secrets: secrets
|
|
213
|
+
? directEntries(lines, secrets.line)
|
|
214
|
+
.map((entry) => definition(lines, entry))
|
|
215
|
+
.sort((a, b) => compareCodeUnits(a.name, b.name))
|
|
216
|
+
: [],
|
|
217
|
+
outputs: outputs
|
|
218
|
+
? directEntries(lines, outputs.line)
|
|
219
|
+
.map((entry) => entry.key)
|
|
220
|
+
.sort()
|
|
221
|
+
: [],
|
|
222
|
+
permissions: parsePermissions(lines, topLevelEntry(lines, "permissions")),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseJobMap(lines, entry) {
|
|
227
|
+
if (!entry) return {};
|
|
228
|
+
return Object.fromEntries(
|
|
229
|
+
directEntries(lines, entry.line).map((item) => [
|
|
230
|
+
item.key,
|
|
231
|
+
scalar(blockValue(lines, item)),
|
|
232
|
+
]),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function parseWorkflowCallJobs(text) {
|
|
237
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
238
|
+
const jobs = topLevelEntry(lines, "jobs");
|
|
239
|
+
if (!jobs) return [];
|
|
240
|
+
const topPermissions = parsePermissions(
|
|
241
|
+
lines,
|
|
242
|
+
topLevelEntry(lines, "permissions"),
|
|
243
|
+
);
|
|
244
|
+
return directEntries(lines, jobs.line)
|
|
245
|
+
.map((job) => {
|
|
246
|
+
const properties = directEntries(lines, job.line);
|
|
247
|
+
const property = (name) =>
|
|
248
|
+
properties.find((entry) => entry.key === name) || null;
|
|
249
|
+
const secrets = property("secrets");
|
|
250
|
+
return {
|
|
251
|
+
id: job.key,
|
|
252
|
+
uses: unquote(property("uses")?.value || ""),
|
|
253
|
+
with: parseJobMap(lines, property("with")),
|
|
254
|
+
secrets:
|
|
255
|
+
secrets?.value === "inherit"
|
|
256
|
+
? "inherit"
|
|
257
|
+
: parseJobMap(lines, secrets),
|
|
258
|
+
permissions: property("permissions")
|
|
259
|
+
? parsePermissions(lines, property("permissions"))
|
|
260
|
+
: topPermissions,
|
|
261
|
+
};
|
|
262
|
+
})
|
|
263
|
+
.filter((job) => job.uses);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function parseWorkflowDocument(text) {
|
|
267
|
+
return {
|
|
268
|
+
triggers: parseTriggers(String(text || "").split(/\r?\n/)),
|
|
269
|
+
interface: parseReusableWorkflowInterface(text),
|
|
270
|
+
callJobs: parseWorkflowCallJobs(text),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -114,7 +114,7 @@ export function validateRenditionSet(output, helpers) {
|
|
|
114
114
|
const captureBytes = readRegular(path.join(output, entry.terminalCapture), `${label} terminal capture`, maxBytes);
|
|
115
115
|
const capture = validateTerminalCapture(JSON.parse(decodeUtf8(captureBytes, `${label} terminal capture`)), scene, helpers);
|
|
116
116
|
invariant(entry.captureRoot === sha256(captureBytes), `${label}.captureRoot mismatch`);
|
|
117
|
-
return { ...entry, transcript, lines, scene, projection, capture };
|
|
117
|
+
return { ...entry, files: Object.fromEntries(["transcript", "projection", "scene", "terminalCapture"].map((key) => [key, entry[key]])), transcript, lines, scene, projection, capture };
|
|
118
118
|
});
|
|
119
119
|
invariant(normalized[0].captureRoot !== normalized[1].captureRoot, "native rendition capture roots must be distinct");
|
|
120
120
|
invariant(
|
|
@@ -1017,6 +1017,15 @@ function verifyRendererOutput(renderOutput, expectedImage, expectedInputs, optio
|
|
|
1017
1017
|
return { manifest, probe, qualification };
|
|
1018
1018
|
}
|
|
1019
1019
|
|
|
1020
|
+
function renditionInputRoots(root, renditions) {
|
|
1021
|
+
return renditions.map((rendition) => ({
|
|
1022
|
+
id: rendition.id, role: rendition.role, captureRoot: rendition.captureRoot,
|
|
1023
|
+
sceneRoot: sha256(readRegular(path.join(root, rendition.files.scene), `${rendition.id} scene`)),
|
|
1024
|
+
transcriptRoot: sha256(readRegular(path.join(root, rendition.files.transcript), `${rendition.id} transcript`)),
|
|
1025
|
+
projectionRoot: sha256(readRegular(path.join(root, rendition.files.projection), `${rendition.id} projection`)),
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1020
1029
|
function finalizeGate(values) {
|
|
1021
1030
|
const adapterOutput = path.resolve(required(values, "--adapter-output"));
|
|
1022
1031
|
const smokeInput = path.resolve(required(values, "--smoke-input"));
|
|
@@ -1109,14 +1118,7 @@ function finalizeGate(values) {
|
|
|
1109
1118
|
renditionSet: {
|
|
1110
1119
|
schema: normalized.renditionSet.schema,
|
|
1111
1120
|
root: sha256(readRegular(path.join(output, "rendition-set.json"), "rendition set")),
|
|
1112
|
-
renditions: normalized.renditionSet.renditions
|
|
1113
|
-
id: rendition.id,
|
|
1114
|
-
role: rendition.role,
|
|
1115
|
-
captureRoot: rendition.captureRoot,
|
|
1116
|
-
sceneRoot: sha256(readRegular(path.join(output, rendition.scene), `${rendition.id} scene`)),
|
|
1117
|
-
transcriptRoot: sha256(readRegular(path.join(output, rendition.transcript), `${rendition.id} transcript`)),
|
|
1118
|
-
projectionRoot: sha256(readRegular(path.join(output, rendition.projection), `${rendition.id} projection`)),
|
|
1119
|
-
})),
|
|
1121
|
+
renditions: renditionInputRoots(output, normalized.renditionSet.renditions),
|
|
1120
1122
|
},
|
|
1121
1123
|
}
|
|
1122
1124
|
: {}),
|
|
@@ -1179,15 +1181,8 @@ function verifyGate(values) {
|
|
|
1179
1181
|
invariant(
|
|
1180
1182
|
qualifiedRenditionSet.schema === normalized.renditionSet.schema
|
|
1181
1183
|
&& qualifiedRenditionSet.root === sha256(readRegular(path.join(bundle, "rendition-set.json"), "rendition set"))
|
|
1182
|
-
&&
|
|
1183
|
-
===
|
|
1184
|
-
id: rendition.id,
|
|
1185
|
-
role: rendition.role,
|
|
1186
|
-
captureRoot: rendition.captureRoot,
|
|
1187
|
-
sceneRoot: sha256(readRegular(path.join(bundle, rendition.scene), `${rendition.id} scene`)),
|
|
1188
|
-
transcriptRoot: sha256(readRegular(path.join(bundle, rendition.transcript), `${rendition.id} transcript`)),
|
|
1189
|
-
projectionRoot: sha256(readRegular(path.join(bundle, rendition.projection), `${rendition.id} projection`)),
|
|
1190
|
-
}))),
|
|
1184
|
+
&& stableJson(qualifiedRenditionSet.renditions)
|
|
1185
|
+
=== stableJson(renditionInputRoots(bundle, normalized.renditionSet.renditions)),
|
|
1191
1186
|
"gate native rendition roots mismatch",
|
|
1192
1187
|
);
|
|
1193
1188
|
}
|
|
@@ -1311,6 +1306,7 @@ export {
|
|
|
1311
1306
|
inspectRendererMedia,
|
|
1312
1307
|
parseAdapterArguments,
|
|
1313
1308
|
qualifyMediaFixture,
|
|
1309
|
+
renditionInputRoots,
|
|
1314
1310
|
prepareSmoke,
|
|
1315
1311
|
runAdapter,
|
|
1316
1312
|
sha256,
|