@async/pipeline 0.9.11 → 0.9.13

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.
@@ -20,8 +20,8 @@ function defineActionRef(id, uses, sha, label) {
20
20
  ref: `${uses}@${sha} # ${label}`
21
21
  };
22
22
  }
23
- const ASYNC_ACTIONS_SHA = "c08a62380ee60fa175d5c3598d41c4485c0ead98";
24
- const ASYNC_ACTIONS_LABEL = "v0.1.12";
23
+ const ASYNC_ACTIONS_SHA = "87e033782ca1f84334d9e3a2543b0db064848fb7";
24
+ const ASYNC_ACTIONS_LABEL = "v0.1.14";
25
25
  const ASYNC_RELEASE_COMMAND = "npx --yes github:async/release#3892d94a4890600d26b812052aa58dec98b05bfb";
26
26
  const GENERATED_ACTIONS = [
27
27
  defineActionRef("async.actions.setup", "async/actions/setup", ASYNC_ACTIONS_SHA, ASYNC_ACTIONS_LABEL),
@@ -37,6 +37,7 @@ const GENERATED_ACTIONS = [
37
37
  defineActionRef("async.actions.source-impact", "async/actions/source-impact", ASYNC_ACTIONS_SHA, ASYNC_ACTIONS_LABEL),
38
38
  defineActionRef("async.actions.cache", "async/actions/cache", ASYNC_ACTIONS_SHA, ASYNC_ACTIONS_LABEL),
39
39
  defineActionRef("async.actions.attest", "async/actions/attest", ASYNC_ACTIONS_SHA, ASYNC_ACTIONS_LABEL),
40
+ defineActionRef("async.actions.contract", "async/actions/contract", ASYNC_ACTIONS_SHA, ASYNC_ACTIONS_LABEL),
40
41
  defineActionRef("actions.checkout", "actions/checkout", "de0fac2e4500dabe0009e67214ff5f5447ce83dd", "v6.0.2"),
41
42
  defineActionRef("pnpm.setup", "pnpm/setup", "cf03a9b516e09bc5a90f041fc26fc930c9dc631b", "v1.0.0"),
42
43
  defineActionRef("deno.setup", "denoland/setup-deno", "667a34cdef165d8d2b2e98dde39547c9daac7282", "v2.0.4"),
@@ -59,6 +60,7 @@ const ASYNC_AGENT_EVIDENCE_ACTION = actionRef("async.actions.agent-evidence");
59
60
  const ASYNC_SOURCE_IMPACT_ACTION = actionRef("async.actions.source-impact");
60
61
  const ASYNC_CACHE_ACTION = actionRef("async.actions.cache");
61
62
  const ASYNC_ATTEST_ACTION = actionRef("async.actions.attest");
63
+ const ASYNC_CONTRACT_ACTION = actionRef("async.actions.contract");
62
64
  const CHECKOUT_ACTION = actionRef("actions.checkout");
63
65
  const PNPM_SETUP_ACTION = actionRef("pnpm.setup");
64
66
  const DENO_SETUP_ACTION = actionRef("deno.setup");
@@ -103,6 +105,7 @@ export async function renderGitHubWorkflow(pipeline, options) {
103
105
  evidence: renderModel.evidence,
104
106
  sourceImpact: renderModel.sourceImpact,
105
107
  attest: renderModel.attest,
108
+ contract: renderModel.contract,
106
109
  bridge: renderModel.bridge,
107
110
  pages: renderModel.pages,
108
111
  manualDispatchJobs: renderModel.manualDispatchJobs,
@@ -133,6 +136,7 @@ export async function renderGitHubWorkflow(pipeline, options) {
133
136
  evidence: renderModel.evidence,
134
137
  sourceImpact: renderModel.sourceImpact,
135
138
  attest: renderModel.attest,
139
+ contract: renderModel.contract,
136
140
  bridge: renderModel.bridge,
137
141
  pages: renderModel.pages,
138
142
  manualDispatchJobs: renderModel.manualDispatchJobs
@@ -241,6 +245,962 @@ export function jobsForGitHubEvent(pipeline, context) {
241
245
  }
242
246
  return matches.sort((left, right) => left.id.localeCompare(right.id));
243
247
  }
248
+ export async function planGitHubJobs(pipeline, options) {
249
+ const rendered = await renderGitHubWorkflow(pipeline, options);
250
+ const packageInfo = await readPackageInfo(options.cwd);
251
+ const model = buildRenderModel(pipeline, {
252
+ ...packageInfo,
253
+ cwd: options.cwd,
254
+ configPath: relativePath(options.cwd, options.configPath),
255
+ workflowPath: rendered.workflowPath
256
+ });
257
+ const event = manifestEventFromOptions(options);
258
+ const network = options.network ?? "mock";
259
+ const candidates = buildManifestCandidates(pipeline, model, rendered, event, network);
260
+ const selected = options.job
261
+ ? candidates.filter((candidate) => candidate.manifest.job.id === options.job)
262
+ : candidates.filter((candidate) => candidate.selected);
263
+ if (options.job && selected.length === 0) {
264
+ throw pipelineError("ASYNC_PIPELINE_GITHUB_PLAN_UNKNOWN_JOB", `Unknown generated GitHub job "${options.job}".`);
265
+ }
266
+ const selectedIds = new Set(selected.map((candidate) => candidate.manifest.job.id));
267
+ return {
268
+ version: 1,
269
+ generatedBy: "@async/pipeline",
270
+ workflow: rendered.workflowPath,
271
+ lock: rendered.lockPath,
272
+ event,
273
+ manifests: selected.map((candidate) => candidate.manifest),
274
+ skippedJobs: candidates
275
+ .filter((candidate) => !selectedIds.has(candidate.manifest.job.id))
276
+ .map((candidate) => ({
277
+ id: candidate.manifest.job.id,
278
+ reason: candidate.skipReason || (options.job ? "job_filter" : "event_filter"),
279
+ trigger: candidate.manifest.job.trigger
280
+ }))
281
+ .sort((left, right) => left.id.localeCompare(right.id))
282
+ };
283
+ }
284
+ export async function runGitHubLocalPlan(pipeline, options) {
285
+ const plan = await planGitHubJobs(pipeline, options);
286
+ if (plan.manifests.length === 0) {
287
+ return { status: "skipped", plan, receipts: [] };
288
+ }
289
+ const receipts = [];
290
+ for (const manifest of plan.manifests) {
291
+ receipts.push(await runGitHubLocalManifest(manifest, options.cwd, {
292
+ env: options.env ?? process.env,
293
+ dryRun: options.dryRun ?? false
294
+ }));
295
+ }
296
+ const status = options.dryRun
297
+ ? "planned"
298
+ : receipts.some((receipt) => receipt.status === "failed")
299
+ ? "failed"
300
+ : "passed";
301
+ return { status, plan, receipts };
302
+ }
303
+ export async function runGitHubLocalManifest(manifest, cwd, options = {}) {
304
+ const env = options.env ?? process.env;
305
+ const dryRun = options.dryRun ?? false;
306
+ const stepReceipts = [];
307
+ const issues = [];
308
+ for (const step of manifest.steps) {
309
+ const stepIssues = validateLocalStep(manifest, step, env);
310
+ issues.push(...stepIssues.map((issue) => `${step.id}: ${issue}`));
311
+ const blockedByNetwork = step.local.networked && manifest.local.network === "deny";
312
+ const status = dryRun ? "planned" : stepIssues.length > 0 ? "failed" : "passed";
313
+ stepReceipts.push({
314
+ id: step.id,
315
+ name: step.name,
316
+ contract: step.local.contract,
317
+ status,
318
+ decision: dryRun
319
+ ? "planned"
320
+ : blockedByNetwork
321
+ ? "denied"
322
+ : manifest.local.network === "allow" && step.local.networked
323
+ ? "allowed"
324
+ : step.local.mode === "shell"
325
+ ? "simulated"
326
+ : "mocked",
327
+ issues: stepIssues
328
+ });
329
+ if (stepIssues.length > 0 && !dryRun)
330
+ break;
331
+ }
332
+ const receipt = {
333
+ job: manifest.job.id,
334
+ status: dryRun ? "planned" : issues.length > 0 ? "failed" : "passed",
335
+ dryRun,
336
+ network: manifest.local.network,
337
+ artifacts: manifest.artifacts,
338
+ stepReceipts,
339
+ issues
340
+ };
341
+ if (!dryRun) {
342
+ const jobDir = resolve(cwd, manifest.local.stateDirectory);
343
+ await mkdir(join(jobDir, "steps"), { recursive: true });
344
+ await mkdir(join(jobDir, "outputs"), { recursive: true });
345
+ await mkdir(join(jobDir, "artifacts"), { recursive: true });
346
+ await writeFile(join(jobDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
347
+ for (const [index, step] of manifest.steps.entries()) {
348
+ await writeFile(join(jobDir, "steps", `${String(index + 1).padStart(2, "0")}-${step.id}.json`), `${JSON.stringify(step, null, 2)}\n`, "utf8");
349
+ }
350
+ for (const artifact of manifest.artifacts) {
351
+ await mkdir(join(jobDir, "artifacts", safeArtifactPart(artifact.name)), { recursive: true });
352
+ }
353
+ receipt.manifestPath = relativePath(cwd, join(jobDir, "manifest.json"));
354
+ await writeFile(join(jobDir, "receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
355
+ }
356
+ return receipt;
357
+ }
358
+ function manifestEventFromOptions(options) {
359
+ const name = options.eventName ?? "workflow_dispatch";
360
+ const selectedJob = options.selectedJob ?? (name === "workflow_dispatch" ? options.job : undefined);
361
+ return {
362
+ name,
363
+ ...(options.eventAction ? { action: options.eventAction } : {}),
364
+ ref: options.ref ?? (name === "push" ? "refs/heads/main" : undefined),
365
+ sha: options.sha,
366
+ actor: options.actor,
367
+ schedule: options.schedule,
368
+ selectedJob,
369
+ pullRequest: name === "pull_request" || name === "pull_request_target"
370
+ ? {
371
+ number: options.prNumber,
372
+ headRepo: options.headRepo,
373
+ headSha: options.headSha,
374
+ baseRef: options.baseRef
375
+ }
376
+ : undefined
377
+ };
378
+ }
379
+ function eventContextFromManifestEvent(event) {
380
+ return {
381
+ eventName: event.name,
382
+ action: event.action,
383
+ ref: event.ref,
384
+ baseRef: event.pullRequest?.baseRef,
385
+ schedule: event.schedule,
386
+ selectedJob: event.selectedJob
387
+ };
388
+ }
389
+ function buildManifestCandidates(pipeline, model, rendered, event, network) {
390
+ const selectedPipelineJobs = new Set(jobsForGitHubEvent(pipeline, eventContextFromManifestEvent(event)).map((job) => job.id));
391
+ const candidates = [];
392
+ for (const job of model.jobs) {
393
+ const selected = selectedPipelineJobs.has(job.id);
394
+ candidates.push({
395
+ manifest: buildPipelineJobManifest(model, rendered, event, job, network),
396
+ selected,
397
+ skipReason: selected ? "" : skipReasonForJob(event, job.trigger)
398
+ });
399
+ }
400
+ for (const generated of buildGeneratedJobManifests(model, rendered, event, network, selectedPipelineJobs)) {
401
+ candidates.push(generated);
402
+ }
403
+ return candidates.sort((left, right) => left.manifest.job.id.localeCompare(right.manifest.job.id));
404
+ }
405
+ function buildPipelineJobManifest(model, rendered, event, job, network) {
406
+ const lifecyclePlan = resolveLifecycleJobPlan(model, job);
407
+ const permissions = manifestJobPermissions(model, job, lifecyclePlan);
408
+ const runnerMatrix = job.github?.runsOnMatrix;
409
+ const steps = [
410
+ checkoutStep(),
411
+ ...setupManifestSteps(model),
412
+ ...dependencyInstallManifestSteps(model),
413
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
414
+ ...taskCacheManifestSteps(model, { kind: "job", id: job.id }),
415
+ ...(lifecyclePlan ? lifecycleManifestSteps(model, job, lifecyclePlan) : [
416
+ runActionManifestStep("run-pipeline-job", "Run pipeline job", `${model.command} github check && ${model.command} run ${shellWord(job.id)}${job.execution ? ` --execution ${shellWord(job.execution)}` : ""}`, scopeTaskRunEnv(job.env, undefined), "run")
417
+ ]),
418
+ ...attestManifestSteps(model, lifecyclePlan, job.requires?.provenance === true),
419
+ ...agentEvidenceManifestSteps(model, job),
420
+ ...taskCacheSaveManifestSteps(model, { kind: "job", id: job.id }),
421
+ ...(job.github?.pages ? [pagesActionManifestStep("upload-pages-artifact", "Upload Pages artifact", job.github.pages)] : []),
422
+ ...evidenceCollectManifestSteps(model)
423
+ ];
424
+ return makeJobManifest(model, rendered, event, {
425
+ id: job.id,
426
+ kind: "pipeline",
427
+ target: job.target,
428
+ runsOn: runnerMatrix && runnerMatrix.length > 0 ? "${{ matrix.runner }}" : job.github?.runsOn ?? "ubuntu-latest",
429
+ matrix: runnerMatrix ? runnerMatrix.map((runner, index) => ({ runner: Array.isArray(runner) ? runner : [runner], index })) : undefined,
430
+ permissions,
431
+ environment: job.environment ?? job.github?.environment ?? null,
432
+ concurrency: null,
433
+ if: job.if ?? null,
434
+ trigger: job.trigger,
435
+ steps,
436
+ network
437
+ });
438
+ }
439
+ function buildGeneratedJobManifests(model, rendered, event, network, selectedPipelineJobs) {
440
+ const candidates = [];
441
+ if (model.pages.enabled && model.pages.target) {
442
+ const selected = generatedPagesSelected(model.pages, event);
443
+ candidates.push({
444
+ manifest: buildGeneratedPagesManifest(model, rendered, event, network),
445
+ selected,
446
+ skipReason: selected ? "" : skipReasonForGeneratedJob(event, [model.pages.job])
447
+ });
448
+ }
449
+ if (model.packagePreviews.enabled && model.packagePreviews.target) {
450
+ const selected = event.name === "pull_request";
451
+ candidates.push({
452
+ manifest: buildPackagePreviewManifest(model, rendered, event, network),
453
+ selected,
454
+ skipReason: selected ? "" : "event_filter"
455
+ });
456
+ }
457
+ if (model.bridge.actionsJob.enabled) {
458
+ const selected = bridgeSelected(model.bridge, event);
459
+ candidates.push({
460
+ manifest: buildBridgeManifest(model, rendered, event, network),
461
+ selected,
462
+ skipReason: selected ? "" : "event_filter"
463
+ });
464
+ }
465
+ if (model.contract.enabled) {
466
+ const selected = contractSelected(model.contract, event);
467
+ candidates.push({
468
+ manifest: buildContractManifest(model, rendered, event, network),
469
+ selected,
470
+ skipReason: selected ? "" : skipReasonForGeneratedJob(event, [model.contract.job])
471
+ });
472
+ }
473
+ if (model.dependabotAutoMerge.enabled) {
474
+ const selected = event.name === "pull_request_target";
475
+ candidates.push({
476
+ manifest: buildDependabotManifest(model, rendered, event, network),
477
+ selected,
478
+ skipReason: selected ? "" : "event_filter"
479
+ });
480
+ }
481
+ for (const sourceJob of model.sourceImpactJobs) {
482
+ const selected = selectedPipelineJobs.has(sourceJob.job);
483
+ candidates.push({
484
+ manifest: buildSourceImpactPlanManifest(model, rendered, event, sourceJob, network),
485
+ selected,
486
+ skipReason: selected ? "" : "upstream_job_skipped"
487
+ });
488
+ candidates.push({
489
+ manifest: buildSourceImpactMatrixManifest(model, rendered, event, sourceJob, network),
490
+ selected,
491
+ skipReason: selected ? "" : "upstream_job_skipped"
492
+ });
493
+ }
494
+ if (model.evidence.enabled) {
495
+ const producerIds = new Set(evidenceProducerJobIds(model));
496
+ const selected = candidates.some((candidate) => candidate.selected && producerIds.has(candidate.manifest.job.id))
497
+ || [...selectedPipelineJobs].some((jobId) => producerIds.has(jobId));
498
+ candidates.push({
499
+ manifest: buildEvidenceFanInManifest(model, rendered, event, network),
500
+ selected,
501
+ skipReason: selected ? "" : "no_evidence_producers"
502
+ });
503
+ }
504
+ return candidates;
505
+ }
506
+ function makeJobManifest(model, rendered, event, options) {
507
+ const stateDirectory = `.async/github-local/jobs/${safeArtifactPart(options.id)}`;
508
+ return {
509
+ version: 1,
510
+ generatedBy: "@async/pipeline",
511
+ repo: "${{ github.repository }}",
512
+ workflow: rendered.workflowPath,
513
+ lock: rendered.lockPath,
514
+ event,
515
+ job: {
516
+ id: options.id,
517
+ name: options.id,
518
+ kind: options.kind,
519
+ target: options.target,
520
+ runsOn: options.runsOn,
521
+ ...(options.matrix ? { matrix: options.matrix } : {}),
522
+ permissions: options.permissions,
523
+ environment: options.environment,
524
+ concurrency: options.concurrency,
525
+ if: options.if,
526
+ trigger: options.trigger
527
+ },
528
+ steps: options.steps,
529
+ trust: {
530
+ actionRefsPinned: ACTION_LOCKS.every((action) => /^[0-9a-f]{40}$/iu.test(action.sha)),
531
+ workflow: rendered.workflowPath,
532
+ lock: rendered.lockPath,
533
+ lifecycleFallbackReason: lifecycleFallbackReason(options.steps)
534
+ },
535
+ artifacts: artifactsForSteps(options.id, options.steps, model.evidence.retentionDays, model.evidence.ifNoFilesFound),
536
+ local: {
537
+ workspace: ".",
538
+ stateDirectory,
539
+ network: options.network,
540
+ permissionsMode: "enforced",
541
+ mocks: [
542
+ "setup",
543
+ "run",
544
+ "pages",
545
+ "preview",
546
+ "publish",
547
+ "storage-bridge",
548
+ "release",
549
+ "contract",
550
+ "hygiene",
551
+ "comment",
552
+ "evidence",
553
+ "agent-evidence",
554
+ "source-impact",
555
+ "cache",
556
+ "attest"
557
+ ]
558
+ }
559
+ };
560
+ }
561
+ function lifecycleFallbackReason(steps) {
562
+ return steps.find((step) => step.local.fallbackReason)?.local.fallbackReason ?? null;
563
+ }
564
+ function manifestJobPermissions(model, job, lifecyclePlan) {
565
+ const grants = job.github?.permissions;
566
+ const idToken = grants?.idToken ?? (job.requires?.provenance || attestRequiresOidc(model, lifecyclePlan) ? "write" : undefined);
567
+ const issues = grants?.issues;
568
+ const packages = grants?.packages;
569
+ const pullRequests = grants?.pullRequests;
570
+ const contents = grants?.contents ?? ((idToken || issues || packages || pullRequests) ? "read" : "read");
571
+ return cleanPermissions({
572
+ contents,
573
+ ...(idToken ? { "id-token": idToken } : {}),
574
+ ...(issues ? { issues } : {}),
575
+ ...(packages ? { packages } : {}),
576
+ ...(pullRequests ? { "pull-requests": pullRequests } : {})
577
+ });
578
+ }
579
+ function cleanPermissions(permissions) {
580
+ return Object.fromEntries(Object.entries(permissions).filter((entry) => entry[1] === "read" || entry[1] === "write"));
581
+ }
582
+ function checkoutStep() {
583
+ return actionManifestStep("checkout", "Checkout", CHECKOUT_ACTION, {}, "checkout");
584
+ }
585
+ function setupManifestSteps(model) {
586
+ const runtime = model.runtime.map((entry) => entry.spec);
587
+ if (model.setup === "async") {
588
+ return [
589
+ actionManifestStep("setup-async-runtimes", "Setup Async runtimes", ASYNC_SETUP_ACTION, {
590
+ "node-version": model.nodeVersion,
591
+ "pnpm-version": pnpmSetupVersion(model.packageManager, model.packageManagerVersion),
592
+ "npm-version": "11.16.0",
593
+ runtime: runtime.length > 1 ? runtime.join("\n") : runtime[0] ?? `node@${model.nodeVersion}`,
594
+ "package-manager": model.packageManager,
595
+ install: model.projectKind === "package",
596
+ "frozen-lockfile": true,
597
+ cache: model.dependencyCache,
598
+ ...(model.dependencyCachePath ? { "dependency-cache-path": model.dependencyCachePath } : {})
599
+ }, "setup")
600
+ ];
601
+ }
602
+ const steps = [actionManifestStep("setup-node", "Setup Node", SETUP_NODE_ACTION, { "node-version": model.nodeVersion }, "setup")];
603
+ if (model.projectKind === "deno") {
604
+ steps.push(actionManifestStep("setup-deno", "Setup Deno", DENO_SETUP_ACTION, { "deno-version": `v${DEFAULT_DENO_VERSION}.x` }, "setup"));
605
+ }
606
+ return steps;
607
+ }
608
+ function dependencyInstallManifestSteps(model) {
609
+ if (model.setup === "async")
610
+ return [];
611
+ return renderDependencyInstallSteps(model).map((_, index) => {
612
+ const command = model.projectKind === "deno"
613
+ ? `deno install --frozen=${model.dependencyCachePath ? "true" : "false"}`
614
+ : `${model.packageManager} install --frozen-lockfile`;
615
+ return shellManifestStep(`install-dependencies-${index}`, "Install dependencies", command, "setup");
616
+ });
617
+ }
618
+ function taskCacheManifestSteps(model, target) {
619
+ if (!model.taskCache)
620
+ return [];
621
+ const manifestPath = target.manifestPath ?? `.async/actions/cache/${safeArtifactPart(target.id)}-cache-manifest.json`;
622
+ return [
623
+ shellManifestStep("write-task-cache-manifest", "Write task cache manifest", renderCacheManifestCommand(model, target, manifestPath, "read-only"), "cache"),
624
+ actionManifestStep("restore-async-task-cache", "Restore Async task cache", ASYNC_CACHE_ACTION, {
625
+ mode: "restore",
626
+ manifest: manifestPath,
627
+ trust: "read-only"
628
+ }, "cache")
629
+ ];
630
+ }
631
+ function taskCacheSaveManifestSteps(model, target) {
632
+ if (!model.taskCache)
633
+ return [];
634
+ const manifestPath = target.manifestPath ?? `.async/actions/cache/${safeArtifactPart(target.id)}-cache-manifest.json`;
635
+ return [
636
+ actionManifestStep("save-async-task-cache", "Save Async task cache", ASYNC_CACHE_ACTION, {
637
+ mode: "save",
638
+ manifest: manifestPath,
639
+ trust: "read-write"
640
+ }, "cache", { if: "${{ success() && github.event_name != 'pull_request' && steps.async-cache-restore.outputs.cache-hit != 'true' }}" })
641
+ ];
642
+ }
643
+ function lifecycleManifestSteps(model, job, plan) {
644
+ const steps = [];
645
+ if (hasReleaseLifecycle(plan)) {
646
+ const packagePath = lifecyclePackagePath(plan) ?? ".";
647
+ steps.push(releaseDoctorManifestStep("plan-release-package", "Plan release package", "plan", packagePath), releaseDoctorManifestStep("inspect-release-package", "Inspect release package", "inspect", packagePath), releaseDoctorManifestStep("check-release-changelog", "Check release changelog", "changelog", packagePath), releaseDoctorManifestStep("render-release-notes", "Render release notes", "notes", packagePath));
648
+ }
649
+ for (const item of plan) {
650
+ if (item.kind === "run-task") {
651
+ steps.push(runActionManifestStep(`run-pipeline-task-${safeArtifactPart(item.taskId)}`, `Run pipeline task ${item.taskId}`, `${model.command} github check && ${model.command} run-task ${shellWord(item.taskId)}`, scopeTaskRunEnv(job.env, model.tasks[item.taskId]), "run", { "artifact-name": `async-pipeline-\${{ github.job }}-${safeArtifactPart(item.taskId)}-runs` }));
652
+ continue;
653
+ }
654
+ if (item.kind === "preview") {
655
+ steps.push(previewManifestStep(item));
656
+ if (item.mode === "pr" && item.comment)
657
+ steps.push(commentManifestStep("comment-package-preview", "Comment package preview"));
658
+ continue;
659
+ }
660
+ if (item.kind === "release") {
661
+ steps.push(releaseDoctorManifestStep("run-release-doctor", "Run release doctor", "doctor", item.packagePath, true));
662
+ continue;
663
+ }
664
+ steps.push(publishManifestStep(item, job.requires?.provenance === true));
665
+ }
666
+ return steps;
667
+ }
668
+ function attestManifestSteps(model, lifecyclePlan, provenance) {
669
+ if (!model.attest.enabled || !hasReleaseLifecycle(lifecyclePlan))
670
+ return [];
671
+ const packagePath = model.attest.packagePath ?? lifecyclePackagePath(lifecyclePlan) ?? ".";
672
+ const steps = [
673
+ actionManifestStep("create-attestation-subject-manifest", "Create attestation subject manifest", ASYNC_ATTEST_ACTION, {
674
+ mode: "digest",
675
+ "package-path": packagePath,
676
+ "subject-manifest": model.attest.subjectManifest,
677
+ "sbom-path": model.attest.sbomPath,
678
+ "require-npm-provenance": model.attest.requireNpmProvenance,
679
+ "tarball-scan": model.attest.tarballScan
680
+ }, "attest"),
681
+ actionManifestStep("write-attestation-sbom-evidence", "Write attestation SBOM evidence", ASYNC_ATTEST_ACTION, {
682
+ mode: "sbom",
683
+ "package-path": packagePath,
684
+ "subject-manifest": model.attest.subjectManifest,
685
+ "sbom-path": model.attest.sbomPath
686
+ }, "attest")
687
+ ];
688
+ if (model.attest.githubAttestation || provenance) {
689
+ steps.push(actionManifestStep("record-github-attestation-intent", "Record GitHub attestation intent", ASYNC_ATTEST_ACTION, {
690
+ mode: "attest",
691
+ "package-path": packagePath,
692
+ "subject-manifest": model.attest.subjectManifest,
693
+ "github-attestation": true
694
+ }, "attest", { permissions: { "id-token": "write" }, networked: true, dangerous: true }));
695
+ }
696
+ return steps;
697
+ }
698
+ function agentEvidenceManifestSteps(model, job) {
699
+ const evidence = agentEvidenceForTargets(model.tasks, job.target);
700
+ if (!evidence.hasAgentStep)
701
+ return [];
702
+ const canComment = job.github?.permissions?.issues === "write" || job.github?.permissions?.pullRequests === "write";
703
+ const steps = [
704
+ actionManifestStep("bundle-agent-evidence", "Bundle agent evidence", ASYNC_AGENT_EVIDENCE_ACTION, {
705
+ mode: canComment ? "comment" : "bundle",
706
+ "run-directory": ".async/runs",
707
+ outputs: evidence.outputs,
708
+ "evidence-path": ".async/actions/agent-evidence/${{ github.job }}/manifest.json",
709
+ "bundle-path": ".async/actions/agent-evidence/${{ github.job }}/bundle.json",
710
+ "receipt-path": ".async/actions/receipts/${{ github.job }}-agent-evidence.json",
711
+ comment: canComment,
712
+ "comment-marker": "async-agent-evidence-${{ github.job }}"
713
+ }, "agent-evidence")
714
+ ];
715
+ if (canComment) {
716
+ steps.push(commentManifestStep("comment-agent-evidence", "Comment agent evidence"));
717
+ }
718
+ return steps;
719
+ }
720
+ function evidenceCollectManifestSteps(model, options = {}) {
721
+ if (!model.evidence.enabled)
722
+ return [];
723
+ const paths = [...new Set([...model.evidence.paths, ...(options.extraPaths ?? [])])];
724
+ return [
725
+ actionManifestStep("collect-evidence-manifest", "Collect evidence manifest", ASYNC_EVIDENCE_ACTION, {
726
+ mode: "collect",
727
+ paths,
728
+ "receipt-paths": model.evidence.receiptPaths,
729
+ "manifest-path": ".async/evidence/${{ github.job }}/manifest.json",
730
+ "summary-path": ".async/evidence/${{ github.job }}/summary.md",
731
+ "artifact-name": `${model.evidence.artifactNamePrefix}-\${{ github.job }}`,
732
+ "retention-days": model.evidence.retentionDays,
733
+ "if-no-files-found": model.evidence.ifNoFilesFound,
734
+ "include-summary": model.evidence.includeSummary
735
+ }, "evidence", { if: "${{ always() }}" })
736
+ ];
737
+ }
738
+ function contractActionInput(contract) {
739
+ return sortObject({
740
+ mode: contract.mode,
741
+ checks: contractChecks(contract).join(","),
742
+ "package-path": contract.packagePath,
743
+ ...(contract.schema.enabled
744
+ ? {
745
+ "schema-sources": contract.schema.sources.join("\n"),
746
+ "schema-output": contract.schema.output
747
+ }
748
+ : {}),
749
+ "evidence-dir": contract.evidenceDir,
750
+ annotations: contract.annotations,
751
+ "fail-on": contract.mode === "report" ? "advisory" : "blocking"
752
+ });
753
+ }
754
+ function contractChecks(contract) {
755
+ const checks = [];
756
+ if (contract.api)
757
+ checks.push("api");
758
+ if (contract.claims)
759
+ checks.push("claims");
760
+ if (contract.schema.enabled)
761
+ checks.push("schema");
762
+ return checks;
763
+ }
764
+ function runActionManifestStep(id, name, command, env, contract, extraWith = {}) {
765
+ const networked = commandLooksNetworked(command);
766
+ return actionManifestStep(id, name, ASYNC_RUN_ACTION, {
767
+ command,
768
+ "check-generated": false,
769
+ "artifact-name": "async-pipeline-${{ github.job }}-runs",
770
+ ...extraWith
771
+ }, contract, { env: manifestEnv(env), secrets: secretNamesFromEnv(env), networked, dangerous: networked });
772
+ }
773
+ function releaseDoctorManifestStep(id, name, mode, packagePath, live = false) {
774
+ return actionManifestStep(id, name, ASYNC_DOCTOR_ACTION, {
775
+ mode,
776
+ "package-path": packagePath,
777
+ "evidence-dir": ".async/release",
778
+ "release-command": ASYNC_RELEASE_COMMAND,
779
+ ...(mode === "doctor" ? { network: live ? "live" : "mock" } : {})
780
+ }, "release", { networked: live, dangerous: live, secrets: live ? ["GITHUB_TOKEN"] : [] });
781
+ }
782
+ function previewManifestStep(preview) {
783
+ return actionManifestStep(`publish-${preview.mode}-package-preview`, `Publish ${preview.mode === "main" ? "main" : "PR"} package preview`, ASYNC_PREVIEW_ACTION, {
784
+ "package-path": preview.packagePath,
785
+ "target-registry": preview.registry,
786
+ ...(preview.namespace ? { namespace: preview.namespace } : {}),
787
+ mode: preview.mode,
788
+ comment: preview.comment,
789
+ "token-env-name": preview.tokenEnv
790
+ }, "preview", { permissions: { packages: "write" }, secrets: [preview.tokenEnv], networked: true, dangerous: true });
791
+ }
792
+ function publishManifestStep(publish, provenance) {
793
+ const name = publish.mode === "github-release"
794
+ ? "Create or update GitHub Release"
795
+ : publish.mode === "github-packages"
796
+ ? "Publish GitHub Packages mirror"
797
+ : "Publish npm package";
798
+ return actionManifestStep(`publish-${publish.mode}`, name, ASYNC_PUBLISH_ACTION, {
799
+ "package-path": publish.packagePath,
800
+ mode: publish.mode,
801
+ registry: publish.registry,
802
+ "dist-tag": publish.distTag,
803
+ ...(publish.mode === "npm" ? { "token-env-name": "NODE_AUTH_TOKEN", provenance } : {}),
804
+ ...(publish.mode === "github-packages" ? { "token-env-name": "GITHUB_TOKEN" } : {}),
805
+ ...(publish.mode === "github-release" ? { "notes-file": ".async/release/release-notes.md" } : {})
806
+ }, "publish", {
807
+ permissions: {
808
+ ...(publish.mode === "github-packages" || publish.mode === "github-release" ? { packages: "write", contents: "write" } : {}),
809
+ ...(provenance ? { "id-token": "write" } : {})
810
+ },
811
+ secrets: publish.mode === "npm" ? ["NODE_AUTH_TOKEN"] : ["GITHUB_TOKEN"],
812
+ networked: true,
813
+ dangerous: true
814
+ });
815
+ }
816
+ function commentManifestStep(id, name) {
817
+ return actionManifestStep(id, name, ASYNC_COMMENT_ACTION, {
818
+ mode: "pr-comment",
819
+ repository: "${{ github.repository }}",
820
+ number: "${{ github.event.pull_request.number }}",
821
+ marker: "${{ steps.source.outputs.comment-marker }}",
822
+ body: "${{ steps.source.outputs.comment-body }}"
823
+ }, "comment", { permissions: { issues: "write" }, secrets: ["GITHUB_TOKEN"], networked: true });
824
+ }
825
+ function pagesActionManifestStep(id, name, pages) {
826
+ return actionManifestStep(id, name, ASYNC_PAGES_ACTION, {
827
+ mode: pages.build.kind,
828
+ ...(pages.build.kind === "jekyll" ? { source: pages.build.source, destination: pages.build.destination ?? "./_site" } : {}),
829
+ ...(pages.build.kind === "static" ? { path: pages.build.path } : {}),
830
+ ...(pages.build.kind === "prerender" ? { path: pages.build.path, "validate-index": pages.build.validateIndex ?? true, "spa-fallback": pages.build.spaFallback ?? false } : {})
831
+ }, "pages", { permissions: { pages: "write", "id-token": "write" } });
832
+ }
833
+ function actionManifestStep(id, name, ref, input, contract, options = {}) {
834
+ const action = actionFromRef(ref);
835
+ return {
836
+ id: safeGeneratedJobId(id),
837
+ name,
838
+ uses: `${action.uses}@${action.sha}`,
839
+ label: action.label,
840
+ ...(options.if ? { if: options.if } : {}),
841
+ with: sortObject(input),
842
+ ...(options.env && Object.keys(options.env).length > 0 ? { env: options.env } : {}),
843
+ permissions: options.permissions ?? {},
844
+ secrets: [...new Set(options.secrets ?? [])].sort(),
845
+ local: {
846
+ contract,
847
+ mode: "action",
848
+ network: options.networked ? "mock" : "mock",
849
+ networked: options.networked ?? false,
850
+ dangerous: options.dangerous ?? false,
851
+ ...(options.networked ? { mockReason: "networked action is mocked unless --network allow is selected" } : {}),
852
+ ...(options.fallbackReason ? { fallbackReason: options.fallbackReason } : {})
853
+ }
854
+ };
855
+ }
856
+ function shellManifestStep(id, name, command, contract) {
857
+ return {
858
+ id: safeGeneratedJobId(id),
859
+ name,
860
+ run: command,
861
+ secrets: [],
862
+ permissions: {},
863
+ local: {
864
+ contract,
865
+ mode: "shell",
866
+ network: "mock",
867
+ networked: commandLooksNetworked(command),
868
+ dangerous: commandLooksDangerous(command),
869
+ ...(commandLooksNetworked(command) ? { mockReason: "networked shell command is simulated unless --network allow is selected" } : {})
870
+ }
871
+ };
872
+ }
873
+ function actionFromRef(ref) {
874
+ const found = GENERATED_ACTIONS.find((action) => action.ref === ref);
875
+ if (found)
876
+ return found;
877
+ const [usesPart, rest] = ref.split("@");
878
+ const sha = rest?.split(/\s+/u)[0] ?? "";
879
+ return { id: usesPart ?? ref, uses: usesPart ?? ref, sha, label: "", ref };
880
+ }
881
+ function manifestEnv(env) {
882
+ return Object.fromEntries(Object.entries(env)
883
+ .map(([name, value]) => [name, renderGitHubEnvValue(value)])
884
+ .filter((entry) => typeof entry[1] === "string")
885
+ .sort(([left], [right]) => left.localeCompare(right)));
886
+ }
887
+ function secretNamesFromEnv(env) {
888
+ const names = new Set();
889
+ for (const [envName, value] of Object.entries(env)) {
890
+ if (isSecretEnvValue(value)) {
891
+ names.add(typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" ? value.name : envName);
892
+ }
893
+ }
894
+ return [...names].sort();
895
+ }
896
+ function artifactsForSteps(jobId, steps, retentionDays, ifNoFilesFound) {
897
+ const artifacts = [];
898
+ for (const step of steps) {
899
+ const artifactName = typeof step.with?.["artifact-name"] === "string" ? step.with["artifact-name"] : undefined;
900
+ if (artifactName) {
901
+ artifacts.push({
902
+ name: artifactName,
903
+ path: artifactPathForContract(step.local.contract),
904
+ mode: step.local.contract === "evidence" ? "upload" : "local",
905
+ producerJob: jobId,
906
+ retentionDays,
907
+ ifNoFilesFound
908
+ });
909
+ }
910
+ }
911
+ return artifacts.sort((left, right) => left.name.localeCompare(right.name));
912
+ }
913
+ function artifactPathForContract(contract) {
914
+ if (contract === "evidence")
915
+ return ".async/evidence";
916
+ if (contract === "agent-evidence")
917
+ return ".async/actions/agent-evidence";
918
+ if (contract === "contract")
919
+ return ".async/contract";
920
+ if (contract === "pages")
921
+ return ".async/pages";
922
+ return ".async/runs";
923
+ }
924
+ function buildGeneratedPagesManifest(model, rendered, event, network) {
925
+ const pages = model.pages;
926
+ const steps = [
927
+ checkoutStep(),
928
+ ...setupManifestSteps(model),
929
+ ...dependencyInstallManifestSteps(model),
930
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
931
+ ...taskCacheManifestSteps(model, { kind: "task", id: pages.target ?? "docs.site" }),
932
+ runActionManifestStep("run-pages-target", "Run Pages target", `${model.command} github check && ${model.command} run-task ${shellWord(pages.target ?? "docs.site")}`, {}, "run"),
933
+ ...taskCacheSaveManifestSteps(model, { kind: "task", id: pages.target ?? "docs.site" }),
934
+ pagesActionManifestStep("upload-pages-artifact", "Upload Pages artifact", pages),
935
+ ...evidenceCollectManifestSteps(model)
936
+ ];
937
+ return makeJobManifest(model, rendered, event, {
938
+ id: pages.job,
939
+ kind: "generated",
940
+ target: pages.target ? [pages.target] : [],
941
+ runsOn: "ubuntu-latest",
942
+ permissions: { contents: "read", pages: "write", "id-token": "write" },
943
+ environment: pages.environment ?? null,
944
+ concurrency: null,
945
+ if: renderGeneratedPagesCondition(pages),
946
+ trigger: ["pull_request", "push", "workflow_dispatch"],
947
+ steps,
948
+ network
949
+ });
950
+ }
951
+ function buildPackagePreviewManifest(model, rendered, event, network) {
952
+ const preview = model.packagePreviews;
953
+ const steps = [
954
+ checkoutStep(),
955
+ ...setupManifestSteps(model),
956
+ ...dependencyInstallManifestSteps(model),
957
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
958
+ ...taskCacheManifestSteps(model, { kind: "task", id: preview.target ?? "pack", manifestPath: ".async/actions/cache/package-preview-cache-manifest.json" }),
959
+ runActionManifestStep("run-package-preview-target", "Run package preview target", `${model.command} github check && ${model.command} run-task ${shellWord(preview.target ?? "pack")}`, {}, "run"),
960
+ ...taskCacheSaveManifestSteps(model, { kind: "task", id: preview.target ?? "pack", manifestPath: ".async/actions/cache/package-preview-cache-manifest.json" }),
961
+ actionManifestStep("publish-package-preview", "Publish package preview", ASYNC_PREVIEW_ACTION, {
962
+ "package-path": preview.package ?? ".",
963
+ "target-registry": preview.registry,
964
+ ...(preview.namespace ? { namespace: preview.namespace } : {}),
965
+ mode: "pr",
966
+ comment: preview.comment,
967
+ "token-env-name": preview.tokenEnv
968
+ }, "preview", { permissions: { packages: "write" }, secrets: [preview.tokenEnv], networked: true, dangerous: true }),
969
+ ...(preview.comment ? [commentManifestStep("comment-package-preview", "Comment package preview")] : []),
970
+ ...evidenceCollectManifestSteps(model)
971
+ ];
972
+ return makeJobManifest(model, rendered, event, {
973
+ id: "package-preview",
974
+ kind: "generated",
975
+ target: preview.target ? [preview.target] : [],
976
+ runsOn: "ubuntu-latest",
977
+ permissions: { contents: "read", issues: "write", packages: "write", "pull-requests": "write" },
978
+ environment: null,
979
+ concurrency: null,
980
+ if: "github.event_name == 'pull_request' && github.event.pull_request.draft == false",
981
+ trigger: ["pull_request"],
982
+ steps,
983
+ network
984
+ });
985
+ }
986
+ function buildBridgeManifest(model, rendered, event, network) {
987
+ const bridge = model.bridge;
988
+ const command = [
989
+ "npx",
990
+ "--yes",
991
+ `@async/github-app@${bridge.packageVersion}`,
992
+ "actions",
993
+ "pull",
994
+ "--branch-prefix",
995
+ bridge.branchPrefix,
996
+ "--pull-request",
997
+ String(bridge.pullRequest),
998
+ ...bridge.allowedPaths.flatMap((path) => ["--allowed-path", path])
999
+ ].map(shellWord).join(" ");
1000
+ const steps = [
1001
+ checkoutStep(),
1002
+ ...setupManifestSteps(model),
1003
+ ...dependencyInstallManifestSteps(model),
1004
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
1005
+ runActionManifestStep("check-generated-workflow", "Check generated workflow", `${model.command} github check`, {}, "run"),
1006
+ runActionManifestStep("pull-and-apply-async-bridge-change-sets", "Pull and apply Async bridge change sets", command, {
1007
+ ASYNC_PROJECT_URL: { kind: "async-pipeline.env.var", name: bridge.endpointVar },
1008
+ ASYNC_PROJECT_TOKEN: { kind: "async-pipeline.env.secret", name: bridge.tokenEnv },
1009
+ GITHUB_TOKEN: { kind: "async-pipeline.env.secret", name: "GITHUB_TOKEN" }
1010
+ }, "storage-bridge"),
1011
+ ...evidenceCollectManifestSteps(model)
1012
+ ];
1013
+ return makeJobManifest(model, rendered, event, {
1014
+ id: bridge.job,
1015
+ kind: "generated",
1016
+ target: [],
1017
+ runsOn: "ubuntu-latest",
1018
+ permissions: { contents: "write", "pull-requests": "write" },
1019
+ environment: null,
1020
+ concurrency: "async-bridge-${{ github.repository }}",
1021
+ if: renderBridgeCondition(bridge),
1022
+ trigger: ["schedule", "workflow_dispatch"],
1023
+ steps,
1024
+ network
1025
+ });
1026
+ }
1027
+ function buildContractManifest(model, rendered, event, network) {
1028
+ const contract = model.contract;
1029
+ const steps = [
1030
+ checkoutStep(),
1031
+ ...setupManifestSteps(model),
1032
+ ...dependencyInstallManifestSteps(model),
1033
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
1034
+ actionManifestStep("run-contract-evidence", "Run contract evidence", ASYNC_CONTRACT_ACTION, contractActionInput(contract), "contract"),
1035
+ ...evidenceCollectManifestSteps(model, { extraPaths: [contract.evidenceDir] })
1036
+ ];
1037
+ return makeJobManifest(model, rendered, event, {
1038
+ id: contract.job,
1039
+ kind: "generated",
1040
+ target: [],
1041
+ runsOn: "ubuntu-latest",
1042
+ permissions: { contents: "read" },
1043
+ environment: null,
1044
+ concurrency: null,
1045
+ if: renderContractCondition(contract),
1046
+ trigger: contract.mode === "release" ? ["release", "workflow_dispatch"] : ["pull_request", "workflow_dispatch"],
1047
+ steps,
1048
+ network
1049
+ });
1050
+ }
1051
+ function buildEvidenceFanInManifest(model, rendered, event, network) {
1052
+ return makeJobManifest(model, rendered, event, {
1053
+ id: model.evidence.job,
1054
+ kind: "generated",
1055
+ target: [],
1056
+ runsOn: "ubuntu-latest",
1057
+ permissions: { contents: "read" },
1058
+ environment: null,
1059
+ concurrency: null,
1060
+ if: "always()",
1061
+ trigger: ["fan-in"],
1062
+ steps: [
1063
+ actionManifestStep("merge-evidence-manifests", "Merge evidence manifests", ASYNC_EVIDENCE_ACTION, {
1064
+ mode: "merge",
1065
+ "artifact-pattern": `${model.evidence.artifactNamePrefix}-*`,
1066
+ "manifest-path": ".async/evidence/index.json",
1067
+ "summary-path": ".async/evidence/index.md",
1068
+ "artifact-name": `${model.evidence.artifactNamePrefix}-index`,
1069
+ "retention-days": model.evidence.retentionDays,
1070
+ "if-no-files-found": model.evidence.ifNoFilesFound,
1071
+ "include-summary": model.evidence.includeSummary
1072
+ }, "evidence")
1073
+ ],
1074
+ network
1075
+ });
1076
+ }
1077
+ function buildDependabotManifest(model, rendered, event, network) {
1078
+ return makeJobManifest(model, rendered, event, {
1079
+ id: "dependabot-auto-merge",
1080
+ kind: "generated",
1081
+ target: [],
1082
+ runsOn: "ubuntu-latest",
1083
+ permissions: { contents: "write", "pull-requests": "write" },
1084
+ environment: null,
1085
+ concurrency: null,
1086
+ if: "github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.draft == false",
1087
+ trigger: ["pull_request_target"],
1088
+ steps: [
1089
+ actionManifestStep("fetch-dependabot-metadata", "Fetch Dependabot metadata", DEPENDABOT_FETCH_METADATA_ACTION, { "github-token": "${{ secrets.GITHUB_TOKEN }}" }, "dependabot", { secrets: ["GITHUB_TOKEN"], networked: true }),
1090
+ actionManifestStep("merge-validated-dependabot-pr", "Merge validated Dependabot PR", ASYNC_DEPENDABOT_MERGE_ACTION, { "allowed-ecosystems": model.dependabotAutoMerge.ecosystems }, "dependabot", { permissions: { contents: "write", "pull-requests": "write" }, secrets: ["GITHUB_TOKEN"], networked: true, dangerous: true })
1091
+ ],
1092
+ network
1093
+ });
1094
+ }
1095
+ function buildSourceImpactPlanManifest(model, rendered, event, sourceJob, network) {
1096
+ return makeJobManifest(model, rendered, event, {
1097
+ id: sourceJob.planJob,
1098
+ kind: "generated",
1099
+ target: [],
1100
+ runsOn: sourceJob.github?.runsOn ?? "ubuntu-latest",
1101
+ permissions: { contents: "read" },
1102
+ environment: null,
1103
+ concurrency: null,
1104
+ if: sourceJob.if ?? null,
1105
+ trigger: ["source-impact"],
1106
+ steps: [
1107
+ checkoutStep(),
1108
+ ...setupManifestSteps(model),
1109
+ shellManifestStep("write-generated-source-plan", "Write generated source plan", `write ${sourceJob.planPath}`, "source-impact"),
1110
+ actionManifestStep("plan-source-impact-matrix", "Plan source impact matrix", ASYNC_SOURCE_IMPACT_ACTION, {
1111
+ mode: "matrix",
1112
+ "plan-path": sourceJob.planPath,
1113
+ "matrix-path": `.async/actions/source-impact/${safeArtifactPart(sourceJob.job)}-matrix.json`
1114
+ }, "source-impact")
1115
+ ],
1116
+ network
1117
+ });
1118
+ }
1119
+ function buildSourceImpactMatrixManifest(model, rendered, event, sourceJob, network) {
1120
+ return makeJobManifest(model, rendered, event, {
1121
+ id: sourceJob.matrixJob,
1122
+ kind: "generated",
1123
+ target: [sourceJob.job],
1124
+ runsOn: sourceJob.github?.runsOn ?? "ubuntu-latest",
1125
+ matrix: sourceJob.plan.matrix.include.map((entry, index) => ({ runner: [String(entry.source)], index })),
1126
+ permissions: { contents: "read" },
1127
+ environment: null,
1128
+ concurrency: null,
1129
+ if: sourceJob.if ?? null,
1130
+ trigger: ["source-impact"],
1131
+ steps: [
1132
+ checkoutStep(),
1133
+ ...setupManifestSteps(model),
1134
+ actionManifestStep("validate-source-impact-row", "Validate source impact row", ASYNC_SOURCE_IMPACT_ACTION, {
1135
+ mode: "validate",
1136
+ source: "${{ matrix.source }}"
1137
+ }, "source-impact"),
1138
+ runActionManifestStep("run-source-impact-task", "Run source impact task", `${model.command} github check && ${model.command} run-task ${shellWord(sourceJob.job)}`, sourceJob.env, "run"),
1139
+ ...evidenceCollectManifestSteps(model)
1140
+ ],
1141
+ network
1142
+ });
1143
+ }
1144
+ function generatedPagesSelected(pages, event) {
1145
+ if (event.name === "pull_request")
1146
+ return pages.triggers.pullRequest;
1147
+ if (event.name === "push")
1148
+ return Boolean(pages.triggers.main);
1149
+ return event.name === "workflow_dispatch" && event.selectedJob === pages.job;
1150
+ }
1151
+ function bridgeSelected(bridge, event) {
1152
+ if (event.name === "schedule")
1153
+ return Boolean(bridge.schedule && (!event.schedule || event.schedule === bridge.schedule));
1154
+ return event.name === "workflow_dispatch" && event.selectedJob === bridge.job;
1155
+ }
1156
+ function contractSelected(contract, event) {
1157
+ if (event.name === "workflow_dispatch")
1158
+ return event.selectedJob === contract.job;
1159
+ if (contract.mode === "release") {
1160
+ return event.name === "release" && (!event.action || event.action === "published");
1161
+ }
1162
+ return event.name === "pull_request";
1163
+ }
1164
+ function skipReasonForJob(event, trigger) {
1165
+ if (event.name === "workflow_dispatch" && !event.selectedJob && trigger.some((id) => id === "manual"))
1166
+ return "manual_selector_missing";
1167
+ return "event_filter";
1168
+ }
1169
+ function skipReasonForGeneratedJob(event, manualIds) {
1170
+ if (event.name === "workflow_dispatch" && !event.selectedJob && manualIds.length > 0)
1171
+ return "manual_selector_missing";
1172
+ return "event_filter";
1173
+ }
1174
+ function validateLocalStep(manifest, step, env) {
1175
+ const issues = [];
1176
+ for (const [permission, required] of Object.entries(step.permissions ?? {})) {
1177
+ const actual = manifest.job.permissions[permission];
1178
+ if (!permissionAllows(actual, required)) {
1179
+ issues.push(`requires ${permission}: ${required}, but job grants ${actual ?? "none"}`);
1180
+ }
1181
+ }
1182
+ if (step.local.networked && manifest.local.network === "deny") {
1183
+ issues.push("networked step is denied by --network deny");
1184
+ }
1185
+ if (step.local.networked && manifest.local.network === "allow") {
1186
+ for (const secret of step.secrets) {
1187
+ if (!env[secret])
1188
+ issues.push(`requires secret env ${secret} for --network allow`);
1189
+ }
1190
+ }
1191
+ return issues;
1192
+ }
1193
+ function permissionAllows(actual, required) {
1194
+ if (required === "read")
1195
+ return actual === "read" || actual === "write";
1196
+ return actual === "write";
1197
+ }
1198
+ function commandLooksNetworked(command) {
1199
+ return /\b(?:gh|npm\s+publish|npx|curl|wget|git\s+(?:push|fetch|pull|clone))\b/u.test(command);
1200
+ }
1201
+ function commandLooksDangerous(command) {
1202
+ return /\b(?:publish|push|release|comment|merge|pull-request)\b/u.test(command);
1203
+ }
244
1204
  function buildRenderModel(pipeline, options) {
245
1205
  const usedTriggerIds = new Set(Object.values(pipeline.jobs).flatMap((job) => job.trigger));
246
1206
  const usedTriggers = Object.fromEntries([...usedTriggerIds].sort().map((triggerId) => [triggerId, pipeline.triggers[triggerId]]));
@@ -255,6 +1215,7 @@ function buildRenderModel(pipeline, options) {
255
1215
  const evidence = resolveGitHubEvidence(pipeline);
256
1216
  const pages = resolveGitHubPages(pipeline);
257
1217
  const bridge = resolveGitHubBridge(pipeline);
1218
+ const contract = resolveGitHubContract(pipeline, { pages, bridge });
258
1219
  if (pages.enabled) {
259
1220
  if (pages.triggers.pullRequest) {
260
1221
  addGitHubEventTrigger(triggers, "pull_request");
@@ -266,6 +1227,14 @@ function buildRenderModel(pipeline, options) {
266
1227
  if (bridge.actionsJob.scheduled && bridge.schedule) {
267
1228
  addScheduleTrigger(triggers, bridge.schedule, "async-bridge");
268
1229
  }
1230
+ if (contract.enabled) {
1231
+ if (contract.mode === "release") {
1232
+ addReleasePublishedTrigger(triggers);
1233
+ }
1234
+ else {
1235
+ addPullRequestTrigger(triggers, "pull_request");
1236
+ }
1237
+ }
269
1238
  const manualDispatchJobs = Object.values(pipeline.jobs)
270
1239
  .filter((job) => job.trigger.some((triggerId) => pipeline.triggers[triggerId]?.type === "manual"))
271
1240
  .map((job) => job.id)
@@ -278,6 +1247,10 @@ function buildRenderModel(pipeline, options) {
278
1247
  manualDispatchJobs.push(bridge.job);
279
1248
  manualDispatchJobs.sort((left, right) => left.localeCompare(right));
280
1249
  }
1250
+ if (contract.enabled) {
1251
+ manualDispatchJobs.push(contract.job);
1252
+ manualDispatchJobs.sort((left, right) => left.localeCompare(right));
1253
+ }
281
1254
  const nodeVersion = pipeline.sync.github.nodeVersion ?? DEFAULT_NODE_VERSION;
282
1255
  const runtime = resolveRuntimeSpecs(pipeline.sync.github.runtime, options.projectKind, nodeVersion);
283
1256
  const setup = resolveGitHubSetup(pipeline.sync.github.setup, options.packageManager, options.packageManagerVersion);
@@ -329,6 +1302,7 @@ function buildRenderModel(pipeline, options) {
329
1302
  evidence,
330
1303
  sourceImpact,
331
1304
  attest: pipeline.sync.github.attest,
1305
+ contract,
332
1306
  bridge,
333
1307
  pages,
334
1308
  manualDispatchJobs
@@ -347,6 +1321,30 @@ function resolveGitHubEvidence(pipeline) {
347
1321
  }
348
1322
  return config;
349
1323
  }
1324
+ function resolveGitHubContract(pipeline, generated) {
1325
+ const config = pipeline.sync.github.contract;
1326
+ if (!config.enabled)
1327
+ return config;
1328
+ const jobId = config.job.toLowerCase();
1329
+ if (Object.keys(pipeline.jobs).some((id) => id.toLowerCase() === jobId)) {
1330
+ throw pipelineError("ASYNC_PIPELINE_GITHUB_CONTRACT_JOB_CONFLICT", `sync.github.contract.job "${config.job}" conflicts with an existing pipeline job. Remove the explicit job or set sync.github.contract.job to a different id.`);
1331
+ }
1332
+ const generatedJobs = new Set();
1333
+ if (pipeline.sync.github.packagePreviews.enabled)
1334
+ generatedJobs.add("package-preview");
1335
+ if (pipeline.sync.github.dependabotAutoMerge.enabled)
1336
+ generatedJobs.add("dependabot-auto-merge");
1337
+ if (pipeline.sync.github.evidence.enabled)
1338
+ generatedJobs.add(pipeline.sync.github.evidence.job);
1339
+ if (generated.pages.enabled)
1340
+ generatedJobs.add(generated.pages.job);
1341
+ if (generated.bridge.actionsJob.enabled)
1342
+ generatedJobs.add(generated.bridge.job);
1343
+ if ([...generatedJobs].some((id) => id.toLowerCase() === jobId)) {
1344
+ throw pipelineError("ASYNC_PIPELINE_GITHUB_CONTRACT_JOB_CONFLICT", `sync.github.contract.job "${config.job}" conflicts with a generated GitHub job. Set sync.github.contract.job to a different id.`);
1345
+ }
1346
+ return config;
1347
+ }
350
1348
  function resolveGitHubSourceImpactJobs(pipeline, cwd, jobs) {
351
1349
  const config = pipeline.sync.github.sourceImpact;
352
1350
  if (!config.enabled)
@@ -359,8 +1357,9 @@ function resolveGitHubSourceImpactJobs(pipeline, cwd, jobs) {
359
1357
  "dependabot-auto-merge",
360
1358
  "async-bridge",
361
1359
  pipeline.sync.github.evidence.job,
1360
+ pipeline.sync.github.contract.enabled ? pipeline.sync.github.contract.job : "",
362
1361
  pipeline.sync.github.pages.job
363
- ].map((id) => id.toLowerCase()));
1362
+ ].filter(Boolean).map((id) => id.toLowerCase()));
364
1363
  const existingJobIds = new Set(Object.keys(pipeline.jobs).map((id) => id.toLowerCase()));
365
1364
  const result = [];
366
1365
  for (const jobId of selectedJobIds) {
@@ -457,6 +1456,16 @@ function addPullRequestTrigger(triggers, event) {
457
1456
  types: [...new Set([...existingTypes, "opened", "reopened", "synchronize", "ready_for_review"])].sort()
458
1457
  });
459
1458
  }
1459
+ function addReleasePublishedTrigger(triggers) {
1460
+ const existing = triggers.release && typeof triggers.release === "object" && !Array.isArray(triggers.release)
1461
+ ? triggers.release
1462
+ : {};
1463
+ const existingTypes = Array.isArray(existing.types) ? existing.types.filter((value) => typeof value === "string") : [];
1464
+ triggers.release = sortObject({
1465
+ ...existing,
1466
+ types: [...new Set([...existingTypes, "published"])].sort()
1467
+ });
1468
+ }
460
1469
  function addGitHubEventTrigger(triggers, event) {
461
1470
  if (triggers[event] === undefined) {
462
1471
  triggers[event] = {};
@@ -581,6 +1590,9 @@ function renderWorkflow(model) {
581
1590
  if (model.bridge.actionsJob.enabled) {
582
1591
  renderBridgeJob(lines, model);
583
1592
  }
1593
+ if (model.contract.enabled) {
1594
+ renderContractJob(lines, model);
1595
+ }
584
1596
  if (model.evidence.enabled) {
585
1597
  renderEvidenceFanInJob(lines, model);
586
1598
  }
@@ -764,7 +1776,8 @@ function renderEvidenceCollectStep(lines, model, options = {}) {
764
1776
  if (!model.evidence.enabled)
765
1777
  return;
766
1778
  const suffix = options.matrix ? "${{ github.job }}-${{ strategy.job-index }}" : "${{ github.job }}";
767
- lines.push("", " - name: Collect evidence manifest", " if: ${{ always() }}", ` uses: ${ASYNC_EVIDENCE_ACTION}`, " with:", " mode: collect", " paths: |", ...model.evidence.paths.map((path) => ` ${path}`), ...(model.evidence.receiptPaths.length > 0
1779
+ const paths = [...new Set([...model.evidence.paths, ...(options.extraPaths ?? [])])];
1780
+ lines.push("", " - name: Collect evidence manifest", " if: ${{ always() }}", ` uses: ${ASYNC_EVIDENCE_ACTION}`, " with:", " mode: collect", " paths: |", ...paths.map((path) => ` ${path}`), ...(model.evidence.receiptPaths.length > 0
768
1781
  ? [
769
1782
  " receipt-paths: |",
770
1783
  ...model.evidence.receiptPaths.map((path) => ` ${path}`)
@@ -1171,6 +2184,32 @@ function renderBridgePullStep(lines, bridge) {
1171
2184
  ].map(shellWord).join(" ");
1172
2185
  lines.push("", " - name: Pull and apply Async bridge change sets", ` uses: ${ASYNC_RUN_ACTION}`, " with:", ` command: ${JSON.stringify(command)}`, " check-generated: false", " artifact-name: async-bridge-${{ github.run_id }}", " env:", " CI: true", ` ASYNC_PROJECT_URL: \${{ vars.${bridge.endpointVar} }}`, ` ASYNC_PROJECT_TOKEN: \${{ secrets.${bridge.tokenEnv} }}`, " GITHUB_REPOSITORY: ${{ github.repository }}", " GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}");
1173
2186
  }
2187
+ function renderContractJob(lines, model) {
2188
+ const contract = model.contract;
2189
+ lines.push(` ${yamlKey(contract.job)}:`, ` name: ${contract.job}`, ` if: ${renderContractCondition(contract)}`, " runs-on: ubuntu-latest", " permissions:", " contents: read", " steps:", " - name: Checkout", ` uses: ${CHECKOUT_ACTION}`, "", ...renderSetupSteps(model), ...renderDependencyInstallSteps(model));
2190
+ if (model.buildCommand) {
2191
+ lines.push("", " - name: Build pipeline CLI", ` run: ${model.buildCommand}`);
2192
+ }
2193
+ renderContractActionStep(lines, contract);
2194
+ renderEvidenceCollectStep(lines, model, { extraPaths: [contract.evidenceDir] });
2195
+ lines.push("");
2196
+ }
2197
+ function renderContractActionStep(lines, contract) {
2198
+ lines.push("", " - name: Run contract evidence", ` uses: ${ASYNC_CONTRACT_ACTION}`, " with:", ` mode: ${contract.mode}`, ` checks: ${JSON.stringify(contractChecks(contract).join(","))}`, ` package-path: ${JSON.stringify(contract.packagePath)}`, ...(contract.schema.enabled
2199
+ ? [
2200
+ " schema-sources: |",
2201
+ ...contract.schema.sources.map((source) => ` ${source}`),
2202
+ ` schema-output: ${JSON.stringify(contract.schema.output)}`
2203
+ ]
2204
+ : []), ` evidence-dir: ${JSON.stringify(contract.evidenceDir)}`, ` annotations: ${contract.annotations ? "true" : "false"}`, ` fail-on: ${contract.mode === "report" ? "advisory" : "blocking"}`);
2205
+ }
2206
+ function renderContractCondition(contract) {
2207
+ const manual = `github.event_name == 'workflow_dispatch' && github.event.inputs.job == '${escapeExpressionString(contract.job)}'`;
2208
+ if (contract.mode === "release") {
2209
+ return `(github.event_name == 'release' && github.event.action == 'published') || (${manual})`;
2210
+ }
2211
+ return `(github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (${manual})`;
2212
+ }
1174
2213
  function renderEvidenceFanInJob(lines, model) {
1175
2214
  const needs = evidenceProducerJobIds(model);
1176
2215
  if (needs.length === 0)
@@ -1190,6 +2229,8 @@ function evidenceProducerJobIds(model) {
1190
2229
  ids.add("package-preview");
1191
2230
  if (model.bridge.actionsJob.enabled)
1192
2231
  ids.add(model.bridge.job);
2232
+ if (model.contract.enabled)
2233
+ ids.add(model.contract.job);
1193
2234
  ids.delete(model.evidence.job);
1194
2235
  return [...ids].sort((left, right) => left.localeCompare(right));
1195
2236
  }