@kungfu-tech/buildchain 4.0.1-alpha.6 → 4.0.1-alpha.8
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/architecture/ci-lane-change-budget.json +187 -0
- package/architecture/maintainability-policy.json +13 -13
- package/architecture/v4-floating-consumer-policy.json +6 -0
- package/contracts/fixtures/v4-tail-reseal-v1/valid.json +1 -1
- package/contracts/v4-floating-consumer-policy-receipt-v1.schema.json +4 -2
- package/dist/site/buildchain-contract.json +9 -9
- package/dist/site/buildchain-site.json +19 -9
- package/dist/site/capability-registry.json +2 -2
- package/dist/site/kfd-claims.json +31 -5
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/node-api-registry.json +154 -73
- package/dist/site/page-registry.json +14 -4
- package/dist/site/public-surface-audit.json +14 -3
- package/dist/site/publication-authority-registry.json +23 -5
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +25 -4
- package/docs/dev-delivery-warrant.md +22 -1
- package/docs/node-api-reference.md +161 -148
- package/package.json +3 -2
- package/packages/core/buildchain-publication-authority.js +1 -0
- package/packages/core/ci-lane-change-budget.js +247 -0
- package/packages/core/dev-delivery-native-proof.js +43 -27
- package/packages/core/dev-delivery-warrant-state.js +31 -8
- package/packages/core/dev-delivery-warrant.js +8 -3
- package/packages/core/github-governance-authority.js +1 -0
- package/packages/core/index.js +5 -0
- package/packages/core/release-candidate-recovery.js +66 -11
- package/packages/core/release-candidate.js +2 -2
- package/packages/core/release-passport.js +10 -1
- package/packages/core/v4-delivery-warrant-read-candidate.js +10 -3
- package/packages/core/v4-floating-consumer-evidence.js +18 -4
- package/packages/core/v4-floating-consumer-policy.js +138 -35
- package/packages/core/v4-floating-consumer-release-passport.js +30 -3
- package/packages/core/workflow-yaml-contract.js +24 -2
- package/scripts/check-ci-lane-change-budget.mjs +100 -0
- package/scripts/check-inventory.mjs +2 -2
- package/scripts/check-v4-floating-consumer-policy-contract.mjs +23 -4
- package/scripts/dev-delivery-warrant-store.mjs +10 -6
- package/scripts/dev-delivery-warrant.mjs +5 -1
- package/scripts/dev-pr-delivery-warrant.mjs +1 -0
- package/scripts/dev-qualification-patrol.mjs +9 -9
- package/scripts/generate-channel-promotion-workflow.mjs +2 -2
- package/scripts/promotion-channel-router.mjs +4 -4
- package/scripts/release-candidate-anchor-provenance.mjs +99 -0
- package/scripts/resume-from-candidate-run.mjs +11 -14
- package/scripts/site-capability-metadata.mjs +1 -0
- package/scripts/v4-consumer-policy.mjs +4 -0
|
@@ -308,6 +308,42 @@ function validateProductPayloads({ passport, artifacts, productPayloadManifests,
|
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
function assertAnchorEqual(actual, expected, label) {
|
|
312
|
+
assertEqual(actual, expected, "anchored-provenance-invalid", label, "Preserve both candidate runs and repair the explicit anchor provenance before retrying.");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function validateAnchorProvenance({ run, passport, anchorProvenance, expectedTransactionId, workflowFile, expectedWorkflowName, repository, targetRef }) {
|
|
316
|
+
const request = anchorProvenance.request;
|
|
317
|
+
const provenanceRun = anchorProvenance.run;
|
|
318
|
+
const provenanceWorkflow = anchorProvenance.workflow;
|
|
319
|
+
const provenancePassport = anchorProvenance.passport;
|
|
320
|
+
assertAnchorEqual(run?.status, "completed", "anchored candidate run status");
|
|
321
|
+
assertAnchorEqual(run?.conclusion, "success", "anchored candidate run conclusion");
|
|
322
|
+
assertAnchorEqual(run?.event, "workflow_dispatch", "anchored candidate run event");
|
|
323
|
+
assertAnchorEqual(passport?.target?.channel, "anchor", "anchored candidate channel");
|
|
324
|
+
assertAnchorEqual(passport?.target?.ref, "publish-gate/anchor", "anchored candidate ref");
|
|
325
|
+
assertAnchorEqual(run?.headSha, passport?.buildchain?.sha, "anchored candidate runtime");
|
|
326
|
+
assertAnchorEqual(request?.schema, 1, "anchor request schema");
|
|
327
|
+
assertAnchorEqual(request?.contract, "kungfu-buildchain-explicit-publish-anchor-request/v1", "anchor request contract");
|
|
328
|
+
assertAnchorEqual(Boolean(request?.assignmentId), true, "anchor request Assignment");
|
|
329
|
+
assertAnchorEqual(/^native:[0-9a-f-]+$/u.test(String(request?.attemptId || "")), true, "anchor request Attempt");
|
|
330
|
+
assertAnchorEqual(request?.transactionId, expectedTransactionId, "anchor request transaction");
|
|
331
|
+
assertAnchorEqual(String(provenanceRun?.id || ""), String(request?.supersededCandidate?.workflowRunId || ""), "superseded candidate run");
|
|
332
|
+
assertAnchorEqual(provenancePassport?.workflow?.runId, String(request?.supersededCandidate?.workflowRunId || ""), "superseded candidate Passport run");
|
|
333
|
+
assertAnchorEqual(request?.source?.sha, passport?.source?.headSha, "anchor request source SHA");
|
|
334
|
+
assertAnchorEqual(request?.source?.tree, passport?.source?.treeHash, "anchor request source tree");
|
|
335
|
+
assertAnchorEqual(request?.supersededCandidate?.sha, provenancePassport?.source?.headSha, "superseded candidate source SHA");
|
|
336
|
+
assertAnchorEqual(request?.supersededCandidate?.root, `sha256:${provenancePassport?.candidateHash || ""}`, "superseded candidate root");
|
|
337
|
+
assertAnchorEqual(request?.runtime?.sha, passport?.buildchain?.sha, "anchor request runtime");
|
|
338
|
+
const provenanceWorkflowPath = String(provenanceWorkflow?.path || provenanceRun?.path || "").split("@")[0].replace(/^\.github\/workflows\//, "");
|
|
339
|
+
assertEqual(provenanceWorkflowPath, workflowFile, "workflow-mismatch", "superseded candidate workflow file", "Use the exact trusted original candidate workflow.");
|
|
340
|
+
assertEqual(String(provenanceWorkflow?.name || provenanceRun?.name || ""), expectedWorkflowName, "workflow-mismatch", "superseded candidate workflow name", "Use the exact trusted original candidate workflow.");
|
|
341
|
+
if (provenanceWorkflow?.state && provenanceWorkflow.state !== "active") fail("untrusted-build-run", `superseded candidate workflow is not active: ${provenanceWorkflow.state}`, "Restore or select an allowed active Build workflow.");
|
|
342
|
+
const validation = validateReleaseCandidatePassport({ passport: provenancePassport, repository, targetChannel: normalizedRef(targetRef).split("/")[0], buildSummary: anchorProvenance.buildSummary });
|
|
343
|
+
if (!validation.ok) fail("anchored-provenance-invalid", `superseded candidate Passport validation failed: ${validation.errors.join("; ")}`, "Preserve both candidate runs and repair the explicit anchor provenance before retrying.");
|
|
344
|
+
return { provenanceRun, provenanceWorkflow, provenancePassport };
|
|
345
|
+
}
|
|
346
|
+
|
|
311
347
|
function validateCandidateProvenance({
|
|
312
348
|
candidateRepository,
|
|
313
349
|
targetRepository,
|
|
@@ -319,26 +355,33 @@ function validateCandidateProvenance({
|
|
|
319
355
|
workflow,
|
|
320
356
|
pullRequest,
|
|
321
357
|
ancestry,
|
|
358
|
+
passport,
|
|
359
|
+
anchorProvenance,
|
|
360
|
+
expectedTransactionId,
|
|
322
361
|
}) {
|
|
323
362
|
const repository = required(candidateRepository, "candidateRepository");
|
|
324
363
|
assertEqual(repository, required(targetRepository, "targetRepository"), "repository-mismatch", "candidate repository", "Dispatch recovery in the repository that created the candidate.");
|
|
325
364
|
assertEqual(String(run?.id || ""), required(expectedRunId, "expectedRunId"), "run-mismatch", "candidate run ID", "Select the exact successful candidate run.");
|
|
326
365
|
assertEqual(run?.repository, repository, "repository-mismatch", "run repository", "Use a candidate run from the target repository.");
|
|
327
366
|
assertEqual(run?.headRepository, repository, "provenance-insufficient", "run head repository", "Fork candidates are not recoverable; create a same-repository candidate.");
|
|
328
|
-
if (run?.status !== "completed" || run?.conclusion !== "success" || run?.event !== "pull_request") {
|
|
329
|
-
fail("untrusted-build-run", "candidate run must be a successful completed pull_request build", "Choose a successful allowed Build workflow run.");
|
|
330
|
-
}
|
|
331
367
|
const workflowFile = required(expectedWorkflowFile, "expectedWorkflowFile").replace(/^\.github\/workflows\//, "");
|
|
332
368
|
const actualWorkflowPath = String(workflow?.path || run?.path || "").split("@")[0].replace(/^\.github\/workflows\//, "");
|
|
333
369
|
assertEqual(actualWorkflowPath, workflowFile, "workflow-mismatch", "candidate workflow file", "Select a run from the documented trusted Build workflow.");
|
|
334
370
|
assertEqual(String(workflow?.name || run?.name || ""), required(expectedWorkflowName, "expectedWorkflowName"), "workflow-mismatch", "candidate workflow name", "Select a run from the documented trusted Build workflow.");
|
|
335
371
|
if (workflow?.state && workflow.state !== "active") fail("untrusted-build-run", `candidate workflow is not active: ${workflow.state}`, "Restore or select an allowed active Build workflow.");
|
|
372
|
+
const anchor = anchorProvenance ? validateAnchorProvenance({ run, passport, anchorProvenance, expectedTransactionId, workflowFile, expectedWorkflowName: required(expectedWorkflowName, "expectedWorkflowName"), repository, targetRef }) : undefined;
|
|
373
|
+
const provenanceRun = anchor?.provenanceRun || run;
|
|
374
|
+
if (provenanceRun?.status !== "completed" || provenanceRun?.conclusion !== "success" || provenanceRun?.event !== "pull_request") {
|
|
375
|
+
fail("untrusted-build-run", "candidate provenance run must be a successful completed pull_request build", "Choose a successful allowed Build workflow run.");
|
|
376
|
+
}
|
|
377
|
+
assertEqual(provenanceRun?.repository, repository, "repository-mismatch", "provenance run repository", "Use a candidate run from the target repository.");
|
|
378
|
+
assertEqual(provenanceRun?.headRepository, repository, "provenance-insufficient", "provenance run head repository", "Fork candidates are not recoverable; create a same-repository candidate.");
|
|
336
379
|
if (!TRUSTED_ASSOCIATIONS.has(String(pullRequest?.authorAssociation || ""))) fail("permission-evidence-insufficient", "candidate PR author lacks trusted repository association evidence", "Have a repository member create or approve a same-repository candidate PR.");
|
|
337
380
|
assertEqual(pullRequest?.headRepository, repository, "provenance-insufficient", "candidate PR head repository", "Fork candidates are outside the recovery permission boundary.");
|
|
338
381
|
if (!pullRequest?.merged) fail("pr-identity-invalid", "candidate PR is not merged", "Use a merged channel candidate PR.");
|
|
339
382
|
assertEqual(normalizedRef(pullRequest?.baseRef), normalizedRef(targetRef), "target-mismatch", "candidate PR target ref", "Use the original release channel target.");
|
|
340
|
-
const boundByNumber = Array.isArray(
|
|
341
|
-
const boundByHead =
|
|
383
|
+
const boundByNumber = Array.isArray(provenanceRun?.pullRequestNumbers) && provenanceRun.pullRequestNumbers.includes(Number(pullRequest?.number));
|
|
384
|
+
const boundByHead = provenanceRun?.headSha === pullRequest?.headSha && normalizedRef(provenanceRun?.headBranch) === normalizedRef(pullRequest?.headRef);
|
|
342
385
|
if (!boundByNumber && !boundByHead) fail("pr-identity-invalid", "candidate run is not bound to the merged candidate PR", "Select the exact PR-stage Build run.");
|
|
343
386
|
if (!ancestry?.mergeIsAncestor && ancestry?.status !== "identical") fail("ancestry-invalid", "candidate merge is not an ancestor of the promotion SHA", "Promote a descendant of the verified merged candidate identity.");
|
|
344
387
|
return { repository, workflowFile };
|
|
@@ -354,12 +397,13 @@ function validateCandidateIdentity({
|
|
|
354
397
|
currentToolingSha,
|
|
355
398
|
passport,
|
|
356
399
|
buildSummary,
|
|
400
|
+
anchorProvenance,
|
|
357
401
|
}) {
|
|
358
402
|
const sha = exactSha(targetSha, "targetSha");
|
|
359
403
|
const tree = exactSha(targetTree, "targetTree");
|
|
360
404
|
const runtimeSha = exactSha(expectedRuntimeSha, "expectedRuntimeSha");
|
|
361
405
|
const toolingSha = exactSha(currentToolingSha, "currentToolingSha");
|
|
362
|
-
const validation = validateReleaseCandidatePassport({ passport, repository, targetChannel: channel, buildSummary });
|
|
406
|
+
const validation = validateReleaseCandidatePassport({ passport, repository, targetChannel: anchorProvenance ? "" : channel, buildSummary });
|
|
363
407
|
if (!validation.ok) fail("passport-invalid", `Release Candidate Passport validation failed: ${validation.errors.join("; ")}`, "Preserve the candidate evidence and explicitly create a new candidate after fixing the producer.");
|
|
364
408
|
const expectedPassportHash = sha256Json({
|
|
365
409
|
repository: passport.repository,
|
|
@@ -369,6 +413,7 @@ function validateCandidateIdentity({
|
|
|
369
413
|
buildchain: passport.buildchain,
|
|
370
414
|
...(passport.gateProfileEvidence ? { gateProfileEvidence: passport.gateProfileEvidence } : {}),
|
|
371
415
|
...(passport.familyEvidence ? { familyEvidence: passport.familyEvidence } : {}),
|
|
416
|
+
...(passport.consumerPolicy ? { consumerPolicy: passport.consumerPolicy } : {}),
|
|
372
417
|
...(passport.controllerReceipts ? { controllerReceipts: passport.controllerReceipts } : {}),
|
|
373
418
|
});
|
|
374
419
|
if (passport.candidateHash !== expectedPassportHash) fail("candidate-root-mismatch", "Release Candidate Passport candidate hash does not match its content", "Preserve the run evidence and explicitly create a new candidate after fixing the producer.");
|
|
@@ -378,7 +423,7 @@ function validateCandidateIdentity({
|
|
|
378
423
|
return { sha, tree, runtimeSha, toolingSha };
|
|
379
424
|
}
|
|
380
425
|
|
|
381
|
-
function validateRecoveryTransaction({ existingTransaction, expectedTransactionId, repository, version, passport, sha, targetRef, candidateRoot }) {
|
|
426
|
+
function validateRecoveryTransaction({ existingTransaction, expectedTransactionId, repository, version, passport, channel, sha, targetRef, candidateRoot }) {
|
|
382
427
|
const actualTransactionId = String(existingTransaction?.id || "");
|
|
383
428
|
if (expectedTransactionId && !actualTransactionId) fail("transaction-identity-conflict", `expected transaction ${expectedTransactionId} does not exist`, "Remove the stale transaction identity only if no durable transaction was ever sealed; otherwise preserve evidence and enter repair_required.");
|
|
384
429
|
if (expectedTransactionId && expectedTransactionId !== actualTransactionId) fail("transaction-identity-conflict", `existing transaction ${actualTransactionId} conflicts with expected ${expectedTransactionId}`, "Enter repair_required and inspect the durable transaction before any retry.");
|
|
@@ -389,7 +434,7 @@ function validateRecoveryTransaction({ existingTransaction, expectedTransactionI
|
|
|
389
434
|
assertEqual(normalizedRef(existingTransaction.target_ref), normalizedRef(targetRef), "transaction-identity-conflict", "durable transaction target ref", "Enter repair_required; never retarget a sealed transaction.");
|
|
390
435
|
assertEqual(existingTransaction.source_sha, sha, "transaction-identity-conflict", "durable transaction source SHA", "Resume with the transaction's exact promotion SHA or enter repair_required.");
|
|
391
436
|
assertEqual(existingTransaction.version, version, "transaction-identity-conflict", "durable transaction version", "Enter repair_required; never change a sealed publication version.");
|
|
392
|
-
assertEqual(existingTransaction.channel, passport.target.channel, "transaction-identity-conflict", "durable transaction channel", "Enter repair_required; never move a transaction between channels.");
|
|
437
|
+
assertEqual(existingTransaction.channel, channel || passport.target.channel, "transaction-identity-conflict", "durable transaction channel", "Enter repair_required; never move a transaction between channels.");
|
|
393
438
|
}
|
|
394
439
|
if (existingTransaction?.candidateRoot && existingTransaction.candidateRoot !== candidateRoot) fail("transaction-identity-conflict", "existing transaction is sealed to a different candidate root", "Enter repair_required; never reuse the conflicting transaction.");
|
|
395
440
|
return actualTransactionId;
|
|
@@ -415,6 +460,7 @@ export function verifyReleaseCandidateRecovery({
|
|
|
415
460
|
workflow,
|
|
416
461
|
pullRequest,
|
|
417
462
|
ancestry,
|
|
463
|
+
anchorProvenance = undefined,
|
|
418
464
|
passport,
|
|
419
465
|
buildSummary,
|
|
420
466
|
controllerReceipts = [],
|
|
@@ -430,10 +476,11 @@ export function verifyReleaseCandidateRecovery({
|
|
|
430
476
|
const { repository, workflowFile } = validateCandidateProvenance({
|
|
431
477
|
candidateRepository, targetRepository, expectedRunId, expectedWorkflowFile,
|
|
432
478
|
expectedWorkflowName, targetRef, run, workflow, pullRequest, ancestry,
|
|
479
|
+
passport, anchorProvenance, expectedTransactionId,
|
|
433
480
|
});
|
|
434
481
|
const { sha, tree, runtimeSha, toolingSha } = validateCandidateIdentity({
|
|
435
482
|
repository, channel, targetSha, targetTree, expectedSourceTree,
|
|
436
|
-
expectedRuntimeSha, currentToolingSha, passport, buildSummary,
|
|
483
|
+
expectedRuntimeSha, currentToolingSha, passport, buildSummary, anchorProvenance,
|
|
437
484
|
});
|
|
438
485
|
|
|
439
486
|
const recoveredArtifacts = artifacts.map(normalizeArtifact).sort((left, right) => left.name.localeCompare(right.name));
|
|
@@ -478,7 +525,7 @@ export function verifyReleaseCandidateRecovery({
|
|
|
478
525
|
|
|
479
526
|
const version = required(publicationVersion || passport.target?.version, "publicationVersion");
|
|
480
527
|
const actualTransactionId = validateRecoveryTransaction({
|
|
481
|
-
existingTransaction, expectedTransactionId, repository, version, passport, sha, targetRef, candidateRoot,
|
|
528
|
+
existingTransaction, expectedTransactionId, repository, version, passport, channel, sha, targetRef, candidateRoot,
|
|
482
529
|
});
|
|
483
530
|
|
|
484
531
|
const receipt = {
|
|
@@ -496,9 +543,17 @@ export function verifyReleaseCandidateRecovery({
|
|
|
496
543
|
sourceSha: passport.source.headSha,
|
|
497
544
|
mergeSha: String(pullRequest.mergeSha || ""),
|
|
498
545
|
tree: passport.source.treeHash,
|
|
546
|
+
...(anchorProvenance ? {
|
|
547
|
+
provenance: "anchored-rematerialization",
|
|
548
|
+
supersededCandidate: {
|
|
549
|
+
runId: String(anchorProvenance.run.id),
|
|
550
|
+
candidateRoot: String(anchorProvenance.request.supersededCandidate.root),
|
|
551
|
+
sourceSha: String(anchorProvenance.request.supersededCandidate.sha),
|
|
552
|
+
},
|
|
553
|
+
} : {}),
|
|
499
554
|
},
|
|
500
555
|
target: {
|
|
501
|
-
channel
|
|
556
|
+
channel,
|
|
502
557
|
ref: normalizedRef(targetRef),
|
|
503
558
|
sha,
|
|
504
559
|
observedRefSha: exactSha(targetRefSha, "targetRefSha"),
|
|
@@ -206,7 +206,7 @@ function validateConsumerPolicyEvidence(passport, check) {
|
|
|
206
206
|
receipt: passport.consumerPolicy.receipt,
|
|
207
207
|
receiptRoot: passport.consumerPolicy.receiptRoot,
|
|
208
208
|
repository: passport.repository,
|
|
209
|
-
sourceSha: passport.source?.headSha,
|
|
209
|
+
sourceSha: passport.target?.ref === "publish-gate/anchor" ? passport.consumerPolicy.receipt?.caller?.sourceSha : passport.source?.headSha,
|
|
210
210
|
resolvedRuntimeSha: passport.buildchain?.sha,
|
|
211
211
|
});
|
|
212
212
|
for (const failure of verification.failures) {
|
|
@@ -403,7 +403,7 @@ export function createReleaseCandidatePassport({
|
|
|
403
403
|
};
|
|
404
404
|
const normalizedConsumerPolicy = normalizeConsumerPolicyEvidence(consumerPolicyReceipt, {
|
|
405
405
|
repository: repository || normalizedSummary.git?.repository || "",
|
|
406
|
-
sourceSha,
|
|
406
|
+
sourceSha: normalizedSummary.publishSource?.ref === "publish-gate/anchor" ? (consumerPolicyReceipt?.receipt || consumerPolicyReceipt)?.caller?.sourceSha : sourceSha,
|
|
407
407
|
runtimeSha: resolvedBuildchain.sha,
|
|
408
408
|
});
|
|
409
409
|
if (isV4BuildchainRuntime(resolvedBuildchain) && !normalizedConsumerPolicy) {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
isV4PromotionRouting,
|
|
39
39
|
releasePassportCertificationVerificationOptions,
|
|
40
40
|
requireV4ConsumerPolicyCertification,
|
|
41
|
+
resolveV4ConsumerPolicyCertificationIdentity,
|
|
41
42
|
} from "./v4-floating-consumer-release-passport.js";
|
|
42
43
|
import {
|
|
43
44
|
verifyV4RuntimeAuthorizationReceipt,
|
|
@@ -1112,17 +1113,25 @@ function normalizePromotionEvidence({
|
|
|
1112
1113
|
release = {},
|
|
1113
1114
|
v4ConsumerPolicyCertification: certification,
|
|
1114
1115
|
v4ConsumerPolicyCertificationRoot: certificationRoot,
|
|
1116
|
+
v4RuntimeResumeEvidence,
|
|
1115
1117
|
repository,
|
|
1116
1118
|
sourceSha,
|
|
1117
1119
|
cwd,
|
|
1118
1120
|
}) {
|
|
1119
1121
|
const routing = normalizePromotionRouting(release.promotionRouting);
|
|
1122
|
+
const identity = resolveV4ConsumerPolicyCertificationIdentity({
|
|
1123
|
+
release,
|
|
1124
|
+
routing,
|
|
1125
|
+
runtimeResume: v4RuntimeResumeEvidence,
|
|
1126
|
+
sourceSha,
|
|
1127
|
+
});
|
|
1120
1128
|
return {
|
|
1121
1129
|
routing,
|
|
1122
1130
|
consumerPolicy: requireV4ConsumerPolicyCertification({
|
|
1123
1131
|
value: certification,
|
|
1124
1132
|
repository,
|
|
1125
|
-
sourceSha,
|
|
1133
|
+
sourceSha: identity.sourceSha,
|
|
1134
|
+
runtimeSha: identity.runtimeSha,
|
|
1126
1135
|
routing,
|
|
1127
1136
|
cwd,
|
|
1128
1137
|
certificationRoot,
|
|
@@ -100,7 +100,9 @@ export function validateV4DeliveryWarrantReadQualification(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export function projectV3QueueToV4ReadState(queueInput) {
|
|
103
|
-
const queue = normalizeDevDeliveryQueue(queueInput
|
|
103
|
+
const queue = normalizeDevDeliveryQueue(queueInput, {
|
|
104
|
+
allowLegacyV3Readback: true,
|
|
105
|
+
});
|
|
104
106
|
return {
|
|
105
107
|
schema: STATE_CONTRACT,
|
|
106
108
|
generation: queue.generation,
|
|
@@ -219,7 +221,9 @@ export async function runV4DeliveryWarrantReadCandidate(
|
|
|
219
221
|
});
|
|
220
222
|
if (typeof retain !== "function")
|
|
221
223
|
fail("retention-required", "v4 read evidence retention is required");
|
|
222
|
-
const queue = normalizeDevDeliveryQueue(queueInput
|
|
224
|
+
const queue = normalizeDevDeliveryQueue(queueInput, {
|
|
225
|
+
allowLegacyV3Readback: true,
|
|
226
|
+
});
|
|
223
227
|
const state = projectV3QueueToV4ReadState(queue);
|
|
224
228
|
const request = createV4DeliveryWarrantReadRequest(state, {
|
|
225
229
|
requestId,
|
|
@@ -235,7 +239,10 @@ export async function runV4DeliveryWarrantReadCandidate(
|
|
|
235
239
|
);
|
|
236
240
|
}
|
|
237
241
|
const projection = validateProjection(response, request, state);
|
|
238
|
-
const observation = observeDevDeliveryQueue(queue, {
|
|
242
|
+
const observation = observeDevDeliveryQueue(queue, {
|
|
243
|
+
now: observedAt,
|
|
244
|
+
allowLegacyV3Readback: true,
|
|
245
|
+
});
|
|
239
246
|
const evidence = {
|
|
240
247
|
schema: V4_DELIVERY_WARRANT_READ_EVIDENCE_CONTRACT,
|
|
241
248
|
authority: "typescript-v3",
|
|
@@ -8,6 +8,12 @@ export const V4_FLOATING_CONSUMER_CERTIFICATION =
|
|
|
8
8
|
"kungfu-buildchain-v4-floating-consumer-certification/v1";
|
|
9
9
|
|
|
10
10
|
const SHA256_ROOT = /^sha256:[0-9a-f]{64}$/u;
|
|
11
|
+
const ALPHA_RECOVERY_BOOTSTRAP = Object.freeze({
|
|
12
|
+
repository: "kungfu-systems/buildchain",
|
|
13
|
+
sourcePath: ".github/workflows/buildchain-ref-promotion-recovery.yml",
|
|
14
|
+
workflow: ".github/workflows/.release-candidate-promote.yml",
|
|
15
|
+
selector: "alpha/v4/v4.0",
|
|
16
|
+
});
|
|
11
17
|
|
|
12
18
|
function stableJson(value) {
|
|
13
19
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
@@ -109,15 +115,23 @@ function verifyAuthorityFields(value, expected, check, label) {
|
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
function verifyFloatingSelector(value, check, label) {
|
|
118
|
+
const bootstrap =
|
|
119
|
+
value?.caller?.repository === ALPHA_RECOVERY_BOOTSTRAP.repository &&
|
|
120
|
+
value?.invocation?.sourcePath === ALPHA_RECOVERY_BOOTSTRAP.sourcePath &&
|
|
121
|
+
value?.invocation?.workflow === ALPHA_RECOVERY_BOOTSTRAP.workflow &&
|
|
122
|
+
value?.invocation?.visibleSelector === ALPHA_RECOVERY_BOOTSTRAP.selector &&
|
|
123
|
+
value?.invocation?.selectorClass === "protected-bootstrap" &&
|
|
124
|
+
value?.invocation?.channel === "alpha";
|
|
112
125
|
check(
|
|
113
|
-
value?.invocation?.selectorClass === "floating",
|
|
126
|
+
value?.invocation?.selectorClass === "floating" || bootstrap,
|
|
114
127
|
"selector-class-invalid",
|
|
115
|
-
`${label} must bind a floating selector`,
|
|
128
|
+
`${label} must bind a floating selector or the bounded protected alpha recovery bootstrap`,
|
|
116
129
|
);
|
|
117
130
|
check(
|
|
118
|
-
["v4", "v4-alpha"].includes(value?.invocation?.visibleSelector)
|
|
131
|
+
["v4", "v4-alpha"].includes(value?.invocation?.visibleSelector) ||
|
|
132
|
+
bootstrap,
|
|
119
133
|
"selector-invalid",
|
|
120
|
-
`${label} selector must be v4
|
|
134
|
+
`${label} selector must be v4, v4-alpha, or the bounded protected alpha recovery bootstrap`,
|
|
121
135
|
);
|
|
122
136
|
}
|
|
123
137
|
|
|
@@ -26,6 +26,11 @@ const EXACT_SHA = /^[0-9a-f]{40}$/u;
|
|
|
26
26
|
const SHA256_ROOT = /^sha256:[0-9a-f]{64}$/u;
|
|
27
27
|
const BUILDCHAIN_REPOSITORY = "kungfu-systems/buildchain";
|
|
28
28
|
const CHANNELS = Object.freeze({ v4: "stable", "v4-alpha": "alpha" });
|
|
29
|
+
const ALPHA_RECOVERY_BOOTSTRAP = Object.freeze({
|
|
30
|
+
selector: "alpha/v4/v4.0",
|
|
31
|
+
sourcePath: ".github/workflows/buildchain-ref-promotion-recovery.yml",
|
|
32
|
+
workflow: ".github/workflows/.release-candidate-promote.yml",
|
|
33
|
+
});
|
|
29
34
|
const DEFAULT_STABLE_LOCK_PATH = ".buildchain/contract-lock.json";
|
|
30
35
|
const DEFAULT_ALPHA_LOCK_PATH = ".buildchain/alpha-contract-lock.json";
|
|
31
36
|
const TRANSIENT_ACTION =
|
|
@@ -277,7 +282,7 @@ function readContractLock(root, relative, expectedRef, label, failures) {
|
|
|
277
282
|
}
|
|
278
283
|
}
|
|
279
284
|
|
|
280
|
-
function classifyBuildchainUses(records, failures) {
|
|
285
|
+
function classifyBuildchainUses(records, failures, { repository } = {}) {
|
|
281
286
|
const uses = [];
|
|
282
287
|
for (const record of records) {
|
|
283
288
|
const parsed = coordinate(record.uses);
|
|
@@ -286,15 +291,27 @@ function classifyBuildchainUses(records, failures) {
|
|
|
286
291
|
parsed.selector === "v4" ||
|
|
287
292
|
parsed.selector === "v4-alpha" ||
|
|
288
293
|
/^v4(?:[./-]|$)/u.test(parsed.selector) ||
|
|
294
|
+
parsed.selector === ALPHA_RECOVERY_BOOTSTRAP.selector ||
|
|
289
295
|
EXACT_SHA.test(parsed.selector.toLowerCase()) ||
|
|
290
296
|
parsed.selector.includes("${{");
|
|
291
297
|
if (!v4Candidate) continue;
|
|
292
|
-
const
|
|
298
|
+
const protectedBootstrap =
|
|
299
|
+
repository === BUILDCHAIN_REPOSITORY &&
|
|
300
|
+
normalizeWorkflowPath(parsed.path) ===
|
|
301
|
+
ALPHA_RECOVERY_BOOTSTRAP.workflow &&
|
|
302
|
+
record.sourcePath === ALPHA_RECOVERY_BOOTSTRAP.sourcePath &&
|
|
303
|
+
parsed.selector === ALPHA_RECOVERY_BOOTSTRAP.selector;
|
|
304
|
+
const channel =
|
|
305
|
+
CHANNELS[parsed.selector] || (protectedBootstrap ? "alpha" : "");
|
|
293
306
|
uses.push({
|
|
294
307
|
...record,
|
|
295
308
|
...parsed,
|
|
296
309
|
channel,
|
|
297
|
-
selectorClass:
|
|
310
|
+
selectorClass: protectedBootstrap
|
|
311
|
+
? "protected-bootstrap"
|
|
312
|
+
: channel
|
|
313
|
+
? "floating"
|
|
314
|
+
: "rejected",
|
|
298
315
|
});
|
|
299
316
|
if (!channel) failures.push(selectorFailure(record, parsed.selector));
|
|
300
317
|
}
|
|
@@ -320,8 +337,97 @@ function selectInvocation(uses, workflow, failures, source = "", channel = "") {
|
|
|
320
337
|
return { expectedWorkflow, selected: invocations[0] };
|
|
321
338
|
}
|
|
322
339
|
|
|
340
|
+
function scanInvocationSource({
|
|
341
|
+
sourceRoot,
|
|
342
|
+
invocationRoot,
|
|
343
|
+
sourceScan,
|
|
344
|
+
sourceUses,
|
|
345
|
+
failures,
|
|
346
|
+
repository,
|
|
347
|
+
}) {
|
|
348
|
+
if (invocationRoot === sourceRoot)
|
|
349
|
+
return { invocationScan: sourceScan, invocationUses: sourceUses };
|
|
350
|
+
try {
|
|
351
|
+
const invocationScan = enumerateUses(invocationRoot);
|
|
352
|
+
return {
|
|
353
|
+
invocationScan,
|
|
354
|
+
invocationUses: classifyBuildchainUses(invocationScan.records, failures, {
|
|
355
|
+
repository,
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
} catch (error) {
|
|
359
|
+
failures.push({
|
|
360
|
+
code: "invocation-source-scan-failed",
|
|
361
|
+
message: error.message,
|
|
362
|
+
});
|
|
363
|
+
return {
|
|
364
|
+
invocationScan: { files: [], records: [] },
|
|
365
|
+
invocationUses: [],
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function sourceScanRoot({
|
|
371
|
+
sourceRoot,
|
|
372
|
+
invocationRoot,
|
|
373
|
+
sourceScan,
|
|
374
|
+
invocationScan,
|
|
375
|
+
invocationUses,
|
|
376
|
+
}) {
|
|
377
|
+
const material =
|
|
378
|
+
invocationRoot === sourceRoot
|
|
379
|
+
? sourceScan
|
|
380
|
+
: {
|
|
381
|
+
source: sourceScan,
|
|
382
|
+
invocation: {
|
|
383
|
+
files: invocationScan.files,
|
|
384
|
+
invocations: invocationUses,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
return sha256(stableJson(material));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function verifySelectedLock({
|
|
391
|
+
selected,
|
|
392
|
+
stable,
|
|
393
|
+
alpha,
|
|
394
|
+
workflowSha,
|
|
395
|
+
failures,
|
|
396
|
+
}) {
|
|
397
|
+
const selectedLock = selected?.channel === "alpha" ? alpha : stable;
|
|
398
|
+
if (!selected?.channel || !selectedLock) return;
|
|
399
|
+
const authorityRef =
|
|
400
|
+
selected.selectorClass === "protected-bootstrap"
|
|
401
|
+
? "v4-alpha"
|
|
402
|
+
: selected.selector;
|
|
403
|
+
try {
|
|
404
|
+
const compatibility = evaluateBuildchainContractLock({
|
|
405
|
+
lock: selectedLock,
|
|
406
|
+
current: createBuildchainContractWorld({ root: defaultRuntimeRoot() }),
|
|
407
|
+
runtimeRef: authorityRef,
|
|
408
|
+
runtimeSha: workflowSha,
|
|
409
|
+
runtimeClass: selected.channel,
|
|
410
|
+
workflowShellRef: authorityRef,
|
|
411
|
+
expectedChannel: selected.channel,
|
|
412
|
+
expectedMajor: "v4",
|
|
413
|
+
});
|
|
414
|
+
if (!compatibility.ok) {
|
|
415
|
+
failures.push({
|
|
416
|
+
code: "selected-lock-runtime-incompatible",
|
|
417
|
+
message: `${selected.channel} contract lock rejects resolved workflow shell ${workflowSha}: ${compatibility.reasons?.join("; ") || compatibility.status}`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
failures.push({
|
|
422
|
+
code: "selected-lock-contract-world-invalid",
|
|
423
|
+
message: `cannot evaluate ${selected.channel} contract lock against the resolved workflow shell: ${error.message}`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
323
428
|
export function scanV4FloatingConsumerPolicy({
|
|
324
429
|
root = process.cwd(),
|
|
430
|
+
invocationRoot = root,
|
|
325
431
|
repository = "",
|
|
326
432
|
sourceSha = "",
|
|
327
433
|
invokedWorkflow = "",
|
|
@@ -335,6 +441,7 @@ export function scanV4FloatingConsumerPolicy({
|
|
|
335
441
|
scannerRoot = "",
|
|
336
442
|
} = {}) {
|
|
337
443
|
const resolvedRoot = path.resolve(root);
|
|
444
|
+
const resolvedInvocationRoot = path.resolve(invocationRoot);
|
|
338
445
|
if (!policy || policy.contract !== V4_FLOATING_CONSUMER_POLICY) {
|
|
339
446
|
throw new Error(`policy must use ${V4_FLOATING_CONSUMER_POLICY}`);
|
|
340
447
|
}
|
|
@@ -374,43 +481,34 @@ export function scanV4FloatingConsumerPolicy({
|
|
|
374
481
|
} catch (error) {
|
|
375
482
|
failures.push({ code: "source-scan-failed", message: error.message });
|
|
376
483
|
}
|
|
377
|
-
const buildchainUses = classifyBuildchainUses(scanned.records, failures
|
|
484
|
+
const buildchainUses = classifyBuildchainUses(scanned.records, failures, {
|
|
485
|
+
repository,
|
|
486
|
+
});
|
|
487
|
+
const { invocationScan, invocationUses } = scanInvocationSource({
|
|
488
|
+
sourceRoot: resolvedRoot,
|
|
489
|
+
invocationRoot: resolvedInvocationRoot,
|
|
490
|
+
sourceScan: scanned,
|
|
491
|
+
sourceUses: buildchainUses,
|
|
492
|
+
failures,
|
|
493
|
+
repository,
|
|
494
|
+
});
|
|
378
495
|
const sourcePath = String(invocationSourcePath || "")
|
|
379
496
|
.split("@")[0]
|
|
380
497
|
.replace(`${repository}/`, "");
|
|
381
498
|
const { expectedWorkflow, selected } = selectInvocation(
|
|
382
|
-
|
|
499
|
+
invocationUses,
|
|
383
500
|
invokedWorkflow,
|
|
384
501
|
failures,
|
|
385
502
|
sourcePath,
|
|
386
503
|
expectedInvocationChannel,
|
|
387
504
|
);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
runtimeSha: normalizedWorkflowSha,
|
|
396
|
-
runtimeClass: selected.channel,
|
|
397
|
-
workflowShellRef: selected.selector,
|
|
398
|
-
expectedChannel: selected.channel,
|
|
399
|
-
expectedMajor: "v4",
|
|
400
|
-
});
|
|
401
|
-
if (!compatibility.ok) {
|
|
402
|
-
failures.push({
|
|
403
|
-
code: "selected-lock-runtime-incompatible",
|
|
404
|
-
message: `${selected.channel} contract lock rejects resolved workflow shell ${normalizedWorkflowSha}: ${compatibility.reasons?.join("; ") || compatibility.status}`,
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
} catch (error) {
|
|
408
|
-
failures.push({
|
|
409
|
-
code: "selected-lock-contract-world-invalid",
|
|
410
|
-
message: `cannot evaluate ${selected.channel} contract lock against the resolved workflow shell: ${error.message}`,
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
}
|
|
505
|
+
verifySelectedLock({
|
|
506
|
+
selected,
|
|
507
|
+
stable,
|
|
508
|
+
alpha,
|
|
509
|
+
workflowSha: normalizedWorkflowSha,
|
|
510
|
+
failures,
|
|
511
|
+
});
|
|
414
512
|
|
|
415
513
|
const policyRoot = sha256(stableJson(policy));
|
|
416
514
|
const resolvedScannerRoot = scannerRoot || policyRoot;
|
|
@@ -424,9 +522,14 @@ export function scanV4FloatingConsumerPolicy({
|
|
|
424
522
|
stable: stable ? sha256(stableJson(stable)) : "",
|
|
425
523
|
alpha: alpha ? sha256(stableJson(alpha)) : "",
|
|
426
524
|
};
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
525
|
+
const sourceScan = { files: scanned.files, invocations: buildchainUses };
|
|
526
|
+
const scanRoot = sourceScanRoot({
|
|
527
|
+
sourceRoot: resolvedRoot,
|
|
528
|
+
invocationRoot: resolvedInvocationRoot,
|
|
529
|
+
sourceScan,
|
|
530
|
+
invocationScan,
|
|
531
|
+
invocationUses,
|
|
532
|
+
});
|
|
430
533
|
const receipt = {
|
|
431
534
|
schema: V4_FLOATING_CONSUMER_RECEIPT,
|
|
432
535
|
status: failures.length ? "failed" : "passed",
|
|
@@ -459,7 +562,7 @@ export function scanV4FloatingConsumerPolicy({
|
|
|
459
562
|
ok: failures.length === 0,
|
|
460
563
|
failures,
|
|
461
564
|
scannedFiles: scanned.files,
|
|
462
|
-
invocations:
|
|
565
|
+
invocations: invocationUses,
|
|
463
566
|
receipt,
|
|
464
567
|
receiptRoot,
|
|
465
568
|
};
|
|
@@ -77,10 +77,31 @@ function normalizeCertification(value, expected) {
|
|
|
77
77
|
return { certificationRoot: verification.certificationRoot, certification };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
export function resolveV4ConsumerPolicyCertificationIdentity({
|
|
81
|
+
release = {},
|
|
82
|
+
routing = {},
|
|
83
|
+
runtimeResume = undefined,
|
|
84
|
+
sourceSha = "",
|
|
85
|
+
} = {}) {
|
|
86
|
+
const builtSourceSha =
|
|
87
|
+
release?.builtSourceSha || release?.built_source_sha || "";
|
|
88
|
+
return {
|
|
89
|
+
sourceSha:
|
|
90
|
+
release?.treeEquivalent === true && builtSourceSha
|
|
91
|
+
? builtSourceSha
|
|
92
|
+
: sourceSha,
|
|
93
|
+
runtimeSha:
|
|
94
|
+
runtimeResume?.lineage?.attempts?.build?.runtimeSha ||
|
|
95
|
+
routing?.runtime?.resolvedSha ||
|
|
96
|
+
"",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
export function requireV4ConsumerPolicyCertification({
|
|
81
101
|
value,
|
|
82
102
|
repository,
|
|
83
103
|
sourceSha,
|
|
104
|
+
runtimeSha = "",
|
|
84
105
|
routing,
|
|
85
106
|
cwd,
|
|
86
107
|
certificationRoot,
|
|
@@ -100,7 +121,7 @@ export function requireV4ConsumerPolicyCertification({
|
|
|
100
121
|
return normalizeCertification(value, {
|
|
101
122
|
repository,
|
|
102
123
|
sourceSha,
|
|
103
|
-
runtimeSha: routing?.runtime?.resolvedSha || "",
|
|
124
|
+
runtimeSha: runtimeSha || routing?.runtime?.resolvedSha || "",
|
|
104
125
|
certificationRoot,
|
|
105
126
|
...(v4Routing ? resolveV4FloatingConsumerCallerLocks(value, cwd) : {}),
|
|
106
127
|
});
|
|
@@ -112,12 +133,18 @@ export function releasePassportCertificationVerificationOptions({
|
|
|
112
133
|
routing,
|
|
113
134
|
}) {
|
|
114
135
|
const authority = evidence?.certification?.authority || {};
|
|
136
|
+
const identity = resolveV4ConsumerPolicyCertificationIdentity({
|
|
137
|
+
release: passport?.release,
|
|
138
|
+
routing,
|
|
139
|
+
runtimeResume: passport?.v4RuntimeResume,
|
|
140
|
+
sourceSha: passport?.release?.sourceSha || "",
|
|
141
|
+
});
|
|
115
142
|
return {
|
|
116
143
|
certification: evidence?.certification,
|
|
117
144
|
certificationRoot: evidence?.certificationRoot,
|
|
118
145
|
repository: passport?.product?.repository || "",
|
|
119
|
-
sourceSha:
|
|
120
|
-
resolvedRuntimeSha:
|
|
146
|
+
sourceSha: identity.sourceSha,
|
|
147
|
+
resolvedRuntimeSha: identity.runtimeSha,
|
|
121
148
|
stableLockRoot: authority.contractLocks?.stable?.root || "",
|
|
122
149
|
alphaLockRoot: authority.contractLocks?.alpha?.root || "",
|
|
123
150
|
stableLockPath: authority.contractLocks?.stable?.path || "",
|
|
@@ -67,8 +67,7 @@ function directEntries(lines, parentLine) {
|
|
|
67
67
|
const trimmed = line.trim();
|
|
68
68
|
const indent = indentation(line);
|
|
69
69
|
if (trimmed && indent <= parentIndent) break;
|
|
70
|
-
if (!trimmed || trimmed.startsWith("#") || indent !== childIndent)
|
|
71
|
-
continue;
|
|
70
|
+
if (!trimmed || trimmed.startsWith("#") || indent !== childIndent) continue;
|
|
72
71
|
const match = line.match(/^\s*([A-Za-z0-9_.-]+):(?:\s*(.*))?$/);
|
|
73
72
|
if (match) {
|
|
74
73
|
entries.push({
|
|
@@ -263,6 +262,28 @@ export function parseWorkflowCallJobs(text) {
|
|
|
263
262
|
.filter((job) => job.uses);
|
|
264
263
|
}
|
|
265
264
|
|
|
265
|
+
export function parseWorkflowJobs(text) {
|
|
266
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
267
|
+
const jobs = topLevelEntry(lines, "jobs");
|
|
268
|
+
if (!jobs) return [];
|
|
269
|
+
return directEntries(lines, jobs.line)
|
|
270
|
+
.map((job) => {
|
|
271
|
+
const properties = directEntries(lines, job.line);
|
|
272
|
+
const property = (name) =>
|
|
273
|
+
properties.find((entry) => entry.key === name) || null;
|
|
274
|
+
return {
|
|
275
|
+
id: job.key,
|
|
276
|
+
name: unquote(property("name")?.value || job.key),
|
|
277
|
+
uses: unquote(property("uses")?.value || ""),
|
|
278
|
+
runsOn: unquote(property("runs-on")?.value || ""),
|
|
279
|
+
timeoutMinutes: property("timeout-minutes")
|
|
280
|
+
? scalar(property("timeout-minutes").value)
|
|
281
|
+
: null,
|
|
282
|
+
};
|
|
283
|
+
})
|
|
284
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
285
|
+
}
|
|
286
|
+
|
|
266
287
|
function blockScalarIndent(lines, lineIndex, parentIndent) {
|
|
267
288
|
for (let index = lineIndex + 1; index < lines.length; index += 1) {
|
|
268
289
|
if (!lines[index].trim()) continue;
|
|
@@ -315,6 +336,7 @@ export function parseWorkflowDocument(text) {
|
|
|
315
336
|
return {
|
|
316
337
|
triggers: parseTriggers(String(text || "").split(/\r?\n/)),
|
|
317
338
|
interface: parseReusableWorkflowInterface(text),
|
|
339
|
+
jobs: parseWorkflowJobs(text),
|
|
318
340
|
callJobs: parseWorkflowCallJobs(text),
|
|
319
341
|
};
|
|
320
342
|
}
|