@async/pipeline 0.9.10 → 0.9.12

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.
@@ -241,6 +241,893 @@ export function jobsForGitHubEvent(pipeline, context) {
241
241
  }
242
242
  return matches.sort((left, right) => left.id.localeCompare(right.id));
243
243
  }
244
+ export async function planGitHubJobs(pipeline, options) {
245
+ const rendered = await renderGitHubWorkflow(pipeline, options);
246
+ const packageInfo = await readPackageInfo(options.cwd);
247
+ const model = buildRenderModel(pipeline, {
248
+ ...packageInfo,
249
+ cwd: options.cwd,
250
+ configPath: relativePath(options.cwd, options.configPath),
251
+ workflowPath: rendered.workflowPath
252
+ });
253
+ const event = manifestEventFromOptions(options);
254
+ const network = options.network ?? "mock";
255
+ const candidates = buildManifestCandidates(pipeline, model, rendered, event, network);
256
+ const selected = options.job
257
+ ? candidates.filter((candidate) => candidate.manifest.job.id === options.job)
258
+ : candidates.filter((candidate) => candidate.selected);
259
+ if (options.job && selected.length === 0) {
260
+ throw pipelineError("ASYNC_PIPELINE_GITHUB_PLAN_UNKNOWN_JOB", `Unknown generated GitHub job "${options.job}".`);
261
+ }
262
+ const selectedIds = new Set(selected.map((candidate) => candidate.manifest.job.id));
263
+ return {
264
+ version: 1,
265
+ generatedBy: "@async/pipeline",
266
+ workflow: rendered.workflowPath,
267
+ lock: rendered.lockPath,
268
+ event,
269
+ manifests: selected.map((candidate) => candidate.manifest),
270
+ skippedJobs: candidates
271
+ .filter((candidate) => !selectedIds.has(candidate.manifest.job.id))
272
+ .map((candidate) => ({
273
+ id: candidate.manifest.job.id,
274
+ reason: candidate.skipReason || (options.job ? "job_filter" : "event_filter"),
275
+ trigger: candidate.manifest.job.trigger
276
+ }))
277
+ .sort((left, right) => left.id.localeCompare(right.id))
278
+ };
279
+ }
280
+ export async function runGitHubLocalPlan(pipeline, options) {
281
+ const plan = await planGitHubJobs(pipeline, options);
282
+ if (plan.manifests.length === 0) {
283
+ return { status: "skipped", plan, receipts: [] };
284
+ }
285
+ const receipts = [];
286
+ for (const manifest of plan.manifests) {
287
+ receipts.push(await runGitHubLocalManifest(manifest, options.cwd, {
288
+ env: options.env ?? process.env,
289
+ dryRun: options.dryRun ?? false
290
+ }));
291
+ }
292
+ const status = options.dryRun
293
+ ? "planned"
294
+ : receipts.some((receipt) => receipt.status === "failed")
295
+ ? "failed"
296
+ : "passed";
297
+ return { status, plan, receipts };
298
+ }
299
+ export async function runGitHubLocalManifest(manifest, cwd, options = {}) {
300
+ const env = options.env ?? process.env;
301
+ const dryRun = options.dryRun ?? false;
302
+ const stepReceipts = [];
303
+ const issues = [];
304
+ for (const step of manifest.steps) {
305
+ const stepIssues = validateLocalStep(manifest, step, env);
306
+ issues.push(...stepIssues.map((issue) => `${step.id}: ${issue}`));
307
+ const blockedByNetwork = step.local.networked && manifest.local.network === "deny";
308
+ const status = dryRun ? "planned" : stepIssues.length > 0 ? "failed" : "passed";
309
+ stepReceipts.push({
310
+ id: step.id,
311
+ name: step.name,
312
+ contract: step.local.contract,
313
+ status,
314
+ decision: dryRun
315
+ ? "planned"
316
+ : blockedByNetwork
317
+ ? "denied"
318
+ : manifest.local.network === "allow" && step.local.networked
319
+ ? "allowed"
320
+ : step.local.mode === "shell"
321
+ ? "simulated"
322
+ : "mocked",
323
+ issues: stepIssues
324
+ });
325
+ if (stepIssues.length > 0 && !dryRun)
326
+ break;
327
+ }
328
+ const receipt = {
329
+ job: manifest.job.id,
330
+ status: dryRun ? "planned" : issues.length > 0 ? "failed" : "passed",
331
+ dryRun,
332
+ network: manifest.local.network,
333
+ artifacts: manifest.artifacts,
334
+ stepReceipts,
335
+ issues
336
+ };
337
+ if (!dryRun) {
338
+ const jobDir = resolve(cwd, manifest.local.stateDirectory);
339
+ await mkdir(join(jobDir, "steps"), { recursive: true });
340
+ await mkdir(join(jobDir, "outputs"), { recursive: true });
341
+ await mkdir(join(jobDir, "artifacts"), { recursive: true });
342
+ await writeFile(join(jobDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
343
+ for (const [index, step] of manifest.steps.entries()) {
344
+ await writeFile(join(jobDir, "steps", `${String(index + 1).padStart(2, "0")}-${step.id}.json`), `${JSON.stringify(step, null, 2)}\n`, "utf8");
345
+ }
346
+ for (const artifact of manifest.artifacts) {
347
+ await mkdir(join(jobDir, "artifacts", safeArtifactPart(artifact.name)), { recursive: true });
348
+ }
349
+ receipt.manifestPath = relativePath(cwd, join(jobDir, "manifest.json"));
350
+ await writeFile(join(jobDir, "receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
351
+ }
352
+ return receipt;
353
+ }
354
+ function manifestEventFromOptions(options) {
355
+ const name = options.eventName ?? "workflow_dispatch";
356
+ const selectedJob = options.selectedJob ?? (name === "workflow_dispatch" ? options.job : undefined);
357
+ return {
358
+ name,
359
+ ...(options.eventAction ? { action: options.eventAction } : {}),
360
+ ref: options.ref ?? (name === "push" ? "refs/heads/main" : undefined),
361
+ sha: options.sha,
362
+ actor: options.actor,
363
+ schedule: options.schedule,
364
+ selectedJob,
365
+ pullRequest: name === "pull_request" || name === "pull_request_target"
366
+ ? {
367
+ number: options.prNumber,
368
+ headRepo: options.headRepo,
369
+ headSha: options.headSha,
370
+ baseRef: options.baseRef
371
+ }
372
+ : undefined
373
+ };
374
+ }
375
+ function eventContextFromManifestEvent(event) {
376
+ return {
377
+ eventName: event.name,
378
+ action: event.action,
379
+ ref: event.ref,
380
+ baseRef: event.pullRequest?.baseRef,
381
+ schedule: event.schedule,
382
+ selectedJob: event.selectedJob
383
+ };
384
+ }
385
+ function buildManifestCandidates(pipeline, model, rendered, event, network) {
386
+ const selectedPipelineJobs = new Set(jobsForGitHubEvent(pipeline, eventContextFromManifestEvent(event)).map((job) => job.id));
387
+ const candidates = [];
388
+ for (const job of model.jobs) {
389
+ const selected = selectedPipelineJobs.has(job.id);
390
+ candidates.push({
391
+ manifest: buildPipelineJobManifest(model, rendered, event, job, network),
392
+ selected,
393
+ skipReason: selected ? "" : skipReasonForJob(event, job.trigger)
394
+ });
395
+ }
396
+ for (const generated of buildGeneratedJobManifests(model, rendered, event, network, selectedPipelineJobs)) {
397
+ candidates.push(generated);
398
+ }
399
+ return candidates.sort((left, right) => left.manifest.job.id.localeCompare(right.manifest.job.id));
400
+ }
401
+ function buildPipelineJobManifest(model, rendered, event, job, network) {
402
+ const lifecyclePlan = resolveLifecycleJobPlan(model, job);
403
+ const permissions = manifestJobPermissions(model, job, lifecyclePlan);
404
+ const runnerMatrix = job.github?.runsOnMatrix;
405
+ const steps = [
406
+ checkoutStep(),
407
+ ...setupManifestSteps(model),
408
+ ...dependencyInstallManifestSteps(model),
409
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
410
+ ...taskCacheManifestSteps(model, { kind: "job", id: job.id }),
411
+ ...(lifecyclePlan ? lifecycleManifestSteps(model, job, lifecyclePlan) : [
412
+ 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")
413
+ ]),
414
+ ...attestManifestSteps(model, lifecyclePlan, job.requires?.provenance === true),
415
+ ...agentEvidenceManifestSteps(model, job),
416
+ ...taskCacheSaveManifestSteps(model, { kind: "job", id: job.id }),
417
+ ...(job.github?.pages ? [pagesActionManifestStep("upload-pages-artifact", "Upload Pages artifact", job.github.pages)] : []),
418
+ ...evidenceCollectManifestSteps(model)
419
+ ];
420
+ return makeJobManifest(model, rendered, event, {
421
+ id: job.id,
422
+ kind: "pipeline",
423
+ target: job.target,
424
+ runsOn: runnerMatrix && runnerMatrix.length > 0 ? "${{ matrix.runner }}" : job.github?.runsOn ?? "ubuntu-latest",
425
+ matrix: runnerMatrix ? runnerMatrix.map((runner, index) => ({ runner: Array.isArray(runner) ? runner : [runner], index })) : undefined,
426
+ permissions,
427
+ environment: job.environment ?? job.github?.environment ?? null,
428
+ concurrency: null,
429
+ if: job.if ?? null,
430
+ trigger: job.trigger,
431
+ steps,
432
+ network
433
+ });
434
+ }
435
+ function buildGeneratedJobManifests(model, rendered, event, network, selectedPipelineJobs) {
436
+ const candidates = [];
437
+ if (model.pages.enabled && model.pages.target) {
438
+ const selected = generatedPagesSelected(model.pages, event);
439
+ candidates.push({
440
+ manifest: buildGeneratedPagesManifest(model, rendered, event, network),
441
+ selected,
442
+ skipReason: selected ? "" : skipReasonForGeneratedJob(event, [model.pages.job])
443
+ });
444
+ }
445
+ if (model.packagePreviews.enabled && model.packagePreviews.target) {
446
+ const selected = event.name === "pull_request";
447
+ candidates.push({
448
+ manifest: buildPackagePreviewManifest(model, rendered, event, network),
449
+ selected,
450
+ skipReason: selected ? "" : "event_filter"
451
+ });
452
+ }
453
+ if (model.bridge.actionsJob.enabled) {
454
+ const selected = bridgeSelected(model.bridge, event);
455
+ candidates.push({
456
+ manifest: buildBridgeManifest(model, rendered, event, network),
457
+ selected,
458
+ skipReason: selected ? "" : "event_filter"
459
+ });
460
+ }
461
+ if (model.dependabotAutoMerge.enabled) {
462
+ const selected = event.name === "pull_request_target";
463
+ candidates.push({
464
+ manifest: buildDependabotManifest(model, rendered, event, network),
465
+ selected,
466
+ skipReason: selected ? "" : "event_filter"
467
+ });
468
+ }
469
+ for (const sourceJob of model.sourceImpactJobs) {
470
+ const selected = selectedPipelineJobs.has(sourceJob.job);
471
+ candidates.push({
472
+ manifest: buildSourceImpactPlanManifest(model, rendered, event, sourceJob, network),
473
+ selected,
474
+ skipReason: selected ? "" : "upstream_job_skipped"
475
+ });
476
+ candidates.push({
477
+ manifest: buildSourceImpactMatrixManifest(model, rendered, event, sourceJob, network),
478
+ selected,
479
+ skipReason: selected ? "" : "upstream_job_skipped"
480
+ });
481
+ }
482
+ if (model.evidence.enabled) {
483
+ const producerIds = new Set(evidenceProducerJobIds(model));
484
+ const selected = candidates.some((candidate) => candidate.selected && producerIds.has(candidate.manifest.job.id))
485
+ || [...selectedPipelineJobs].some((jobId) => producerIds.has(jobId));
486
+ candidates.push({
487
+ manifest: buildEvidenceFanInManifest(model, rendered, event, network),
488
+ selected,
489
+ skipReason: selected ? "" : "no_evidence_producers"
490
+ });
491
+ }
492
+ return candidates;
493
+ }
494
+ function makeJobManifest(model, rendered, event, options) {
495
+ const stateDirectory = `.async/github-local/jobs/${safeArtifactPart(options.id)}`;
496
+ return {
497
+ version: 1,
498
+ generatedBy: "@async/pipeline",
499
+ repo: "${{ github.repository }}",
500
+ workflow: rendered.workflowPath,
501
+ lock: rendered.lockPath,
502
+ event,
503
+ job: {
504
+ id: options.id,
505
+ name: options.id,
506
+ kind: options.kind,
507
+ target: options.target,
508
+ runsOn: options.runsOn,
509
+ ...(options.matrix ? { matrix: options.matrix } : {}),
510
+ permissions: options.permissions,
511
+ environment: options.environment,
512
+ concurrency: options.concurrency,
513
+ if: options.if,
514
+ trigger: options.trigger
515
+ },
516
+ steps: options.steps,
517
+ trust: {
518
+ actionRefsPinned: ACTION_LOCKS.every((action) => /^[0-9a-f]{40}$/iu.test(action.sha)),
519
+ workflow: rendered.workflowPath,
520
+ lock: rendered.lockPath,
521
+ lifecycleFallbackReason: lifecycleFallbackReason(options.steps)
522
+ },
523
+ artifacts: artifactsForSteps(options.id, options.steps, model.evidence.retentionDays, model.evidence.ifNoFilesFound),
524
+ local: {
525
+ workspace: ".",
526
+ stateDirectory,
527
+ network: options.network,
528
+ permissionsMode: "enforced",
529
+ mocks: [
530
+ "setup",
531
+ "run",
532
+ "pages",
533
+ "preview",
534
+ "publish",
535
+ "storage-bridge",
536
+ "release",
537
+ "contract",
538
+ "hygiene",
539
+ "comment",
540
+ "evidence",
541
+ "agent-evidence",
542
+ "source-impact",
543
+ "cache",
544
+ "attest"
545
+ ]
546
+ }
547
+ };
548
+ }
549
+ function lifecycleFallbackReason(steps) {
550
+ return steps.find((step) => step.local.fallbackReason)?.local.fallbackReason ?? null;
551
+ }
552
+ function manifestJobPermissions(model, job, lifecyclePlan) {
553
+ const grants = job.github?.permissions;
554
+ const idToken = grants?.idToken ?? (job.requires?.provenance || attestRequiresOidc(model, lifecyclePlan) ? "write" : undefined);
555
+ const issues = grants?.issues;
556
+ const packages = grants?.packages;
557
+ const pullRequests = grants?.pullRequests;
558
+ const contents = grants?.contents ?? ((idToken || issues || packages || pullRequests) ? "read" : "read");
559
+ return cleanPermissions({
560
+ contents,
561
+ ...(idToken ? { "id-token": idToken } : {}),
562
+ ...(issues ? { issues } : {}),
563
+ ...(packages ? { packages } : {}),
564
+ ...(pullRequests ? { "pull-requests": pullRequests } : {})
565
+ });
566
+ }
567
+ function cleanPermissions(permissions) {
568
+ return Object.fromEntries(Object.entries(permissions).filter((entry) => entry[1] === "read" || entry[1] === "write"));
569
+ }
570
+ function checkoutStep() {
571
+ return actionManifestStep("checkout", "Checkout", CHECKOUT_ACTION, {}, "checkout");
572
+ }
573
+ function setupManifestSteps(model) {
574
+ const runtime = model.runtime.map((entry) => entry.spec);
575
+ if (model.setup === "async") {
576
+ return [
577
+ actionManifestStep("setup-async-runtimes", "Setup Async runtimes", ASYNC_SETUP_ACTION, {
578
+ "node-version": model.nodeVersion,
579
+ "pnpm-version": pnpmSetupVersion(model.packageManager, model.packageManagerVersion),
580
+ "npm-version": "11.16.0",
581
+ runtime: runtime.length > 1 ? runtime.join("\n") : runtime[0] ?? `node@${model.nodeVersion}`,
582
+ "package-manager": model.packageManager,
583
+ install: model.projectKind === "package",
584
+ "frozen-lockfile": true,
585
+ cache: model.dependencyCache,
586
+ ...(model.dependencyCachePath ? { "dependency-cache-path": model.dependencyCachePath } : {})
587
+ }, "setup")
588
+ ];
589
+ }
590
+ const steps = [actionManifestStep("setup-node", "Setup Node", SETUP_NODE_ACTION, { "node-version": model.nodeVersion }, "setup")];
591
+ if (model.projectKind === "deno") {
592
+ steps.push(actionManifestStep("setup-deno", "Setup Deno", DENO_SETUP_ACTION, { "deno-version": `v${DEFAULT_DENO_VERSION}.x` }, "setup"));
593
+ }
594
+ return steps;
595
+ }
596
+ function dependencyInstallManifestSteps(model) {
597
+ if (model.setup === "async")
598
+ return [];
599
+ return renderDependencyInstallSteps(model).map((_, index) => {
600
+ const command = model.projectKind === "deno"
601
+ ? `deno install --frozen=${model.dependencyCachePath ? "true" : "false"}`
602
+ : `${model.packageManager} install --frozen-lockfile`;
603
+ return shellManifestStep(`install-dependencies-${index}`, "Install dependencies", command, "setup");
604
+ });
605
+ }
606
+ function taskCacheManifestSteps(model, target) {
607
+ if (!model.taskCache)
608
+ return [];
609
+ const manifestPath = target.manifestPath ?? `.async/actions/cache/${safeArtifactPart(target.id)}-cache-manifest.json`;
610
+ return [
611
+ shellManifestStep("write-task-cache-manifest", "Write task cache manifest", renderCacheManifestCommand(model, target, manifestPath, "read-only"), "cache"),
612
+ actionManifestStep("restore-async-task-cache", "Restore Async task cache", ASYNC_CACHE_ACTION, {
613
+ mode: "restore",
614
+ manifest: manifestPath,
615
+ trust: "read-only"
616
+ }, "cache")
617
+ ];
618
+ }
619
+ function taskCacheSaveManifestSteps(model, target) {
620
+ if (!model.taskCache)
621
+ return [];
622
+ const manifestPath = target.manifestPath ?? `.async/actions/cache/${safeArtifactPart(target.id)}-cache-manifest.json`;
623
+ return [
624
+ actionManifestStep("save-async-task-cache", "Save Async task cache", ASYNC_CACHE_ACTION, {
625
+ mode: "save",
626
+ manifest: manifestPath,
627
+ trust: "read-write"
628
+ }, "cache", { if: "${{ success() && github.event_name != 'pull_request' && steps.async-cache-restore.outputs.cache-hit != 'true' }}" })
629
+ ];
630
+ }
631
+ function lifecycleManifestSteps(model, job, plan) {
632
+ const steps = [];
633
+ if (hasReleaseLifecycle(plan)) {
634
+ const packagePath = lifecyclePackagePath(plan) ?? ".";
635
+ 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));
636
+ }
637
+ for (const item of plan) {
638
+ if (item.kind === "run-task") {
639
+ 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` }));
640
+ continue;
641
+ }
642
+ if (item.kind === "preview") {
643
+ steps.push(previewManifestStep(item));
644
+ if (item.mode === "pr" && item.comment)
645
+ steps.push(commentManifestStep("comment-package-preview", "Comment package preview"));
646
+ continue;
647
+ }
648
+ if (item.kind === "release") {
649
+ steps.push(releaseDoctorManifestStep("run-release-doctor", "Run release doctor", "doctor", item.packagePath, true));
650
+ continue;
651
+ }
652
+ steps.push(publishManifestStep(item, job.requires?.provenance === true));
653
+ }
654
+ return steps;
655
+ }
656
+ function attestManifestSteps(model, lifecyclePlan, provenance) {
657
+ if (!model.attest.enabled || !hasReleaseLifecycle(lifecyclePlan))
658
+ return [];
659
+ const packagePath = model.attest.packagePath ?? lifecyclePackagePath(lifecyclePlan) ?? ".";
660
+ const steps = [
661
+ actionManifestStep("create-attestation-subject-manifest", "Create attestation subject manifest", ASYNC_ATTEST_ACTION, {
662
+ mode: "digest",
663
+ "package-path": packagePath,
664
+ "subject-manifest": model.attest.subjectManifest,
665
+ "sbom-path": model.attest.sbomPath,
666
+ "require-npm-provenance": model.attest.requireNpmProvenance,
667
+ "tarball-scan": model.attest.tarballScan
668
+ }, "attest"),
669
+ actionManifestStep("write-attestation-sbom-evidence", "Write attestation SBOM evidence", ASYNC_ATTEST_ACTION, {
670
+ mode: "sbom",
671
+ "package-path": packagePath,
672
+ "subject-manifest": model.attest.subjectManifest,
673
+ "sbom-path": model.attest.sbomPath
674
+ }, "attest")
675
+ ];
676
+ if (model.attest.githubAttestation || provenance) {
677
+ steps.push(actionManifestStep("record-github-attestation-intent", "Record GitHub attestation intent", ASYNC_ATTEST_ACTION, {
678
+ mode: "attest",
679
+ "package-path": packagePath,
680
+ "subject-manifest": model.attest.subjectManifest,
681
+ "github-attestation": true
682
+ }, "attest", { permissions: { "id-token": "write" }, networked: true, dangerous: true }));
683
+ }
684
+ return steps;
685
+ }
686
+ function agentEvidenceManifestSteps(model, job) {
687
+ const evidence = agentEvidenceForTargets(model.tasks, job.target);
688
+ if (!evidence.hasAgentStep)
689
+ return [];
690
+ const canComment = job.github?.permissions?.issues === "write" || job.github?.permissions?.pullRequests === "write";
691
+ const steps = [
692
+ actionManifestStep("bundle-agent-evidence", "Bundle agent evidence", ASYNC_AGENT_EVIDENCE_ACTION, {
693
+ mode: canComment ? "comment" : "bundle",
694
+ "run-directory": ".async/runs",
695
+ outputs: evidence.outputs,
696
+ "evidence-path": ".async/actions/agent-evidence/${{ github.job }}/manifest.json",
697
+ "bundle-path": ".async/actions/agent-evidence/${{ github.job }}/bundle.json",
698
+ "receipt-path": ".async/actions/receipts/${{ github.job }}-agent-evidence.json",
699
+ comment: canComment,
700
+ "comment-marker": "async-agent-evidence-${{ github.job }}"
701
+ }, "agent-evidence")
702
+ ];
703
+ if (canComment) {
704
+ steps.push(commentManifestStep("comment-agent-evidence", "Comment agent evidence"));
705
+ }
706
+ return steps;
707
+ }
708
+ function evidenceCollectManifestSteps(model) {
709
+ if (!model.evidence.enabled)
710
+ return [];
711
+ return [
712
+ actionManifestStep("collect-evidence-manifest", "Collect evidence manifest", ASYNC_EVIDENCE_ACTION, {
713
+ mode: "collect",
714
+ paths: model.evidence.paths,
715
+ "receipt-paths": model.evidence.receiptPaths,
716
+ "manifest-path": ".async/evidence/${{ github.job }}/manifest.json",
717
+ "summary-path": ".async/evidence/${{ github.job }}/summary.md",
718
+ "artifact-name": `${model.evidence.artifactNamePrefix}-\${{ github.job }}`,
719
+ "retention-days": model.evidence.retentionDays,
720
+ "if-no-files-found": model.evidence.ifNoFilesFound,
721
+ "include-summary": model.evidence.includeSummary
722
+ }, "evidence", { if: "${{ always() }}" })
723
+ ];
724
+ }
725
+ function runActionManifestStep(id, name, command, env, contract, extraWith = {}) {
726
+ const networked = commandLooksNetworked(command);
727
+ return actionManifestStep(id, name, ASYNC_RUN_ACTION, {
728
+ command,
729
+ "check-generated": false,
730
+ "artifact-name": "async-pipeline-${{ github.job }}-runs",
731
+ ...extraWith
732
+ }, contract, { env: manifestEnv(env), secrets: secretNamesFromEnv(env), networked, dangerous: networked });
733
+ }
734
+ function releaseDoctorManifestStep(id, name, mode, packagePath, live = false) {
735
+ return actionManifestStep(id, name, ASYNC_DOCTOR_ACTION, {
736
+ mode,
737
+ "package-path": packagePath,
738
+ "evidence-dir": ".async/release",
739
+ "release-command": ASYNC_RELEASE_COMMAND,
740
+ ...(mode === "doctor" ? { network: live ? "live" : "mock" } : {})
741
+ }, "release", { networked: live, dangerous: live, secrets: live ? ["GITHUB_TOKEN"] : [] });
742
+ }
743
+ function previewManifestStep(preview) {
744
+ return actionManifestStep(`publish-${preview.mode}-package-preview`, `Publish ${preview.mode === "main" ? "main" : "PR"} package preview`, ASYNC_PREVIEW_ACTION, {
745
+ "package-path": preview.packagePath,
746
+ "target-registry": preview.registry,
747
+ ...(preview.namespace ? { namespace: preview.namespace } : {}),
748
+ mode: preview.mode,
749
+ comment: preview.comment,
750
+ "token-env-name": preview.tokenEnv
751
+ }, "preview", { permissions: { packages: "write" }, secrets: [preview.tokenEnv], networked: true, dangerous: true });
752
+ }
753
+ function publishManifestStep(publish, provenance) {
754
+ const name = publish.mode === "github-release"
755
+ ? "Create or update GitHub Release"
756
+ : publish.mode === "github-packages"
757
+ ? "Publish GitHub Packages mirror"
758
+ : "Publish npm package";
759
+ return actionManifestStep(`publish-${publish.mode}`, name, ASYNC_PUBLISH_ACTION, {
760
+ "package-path": publish.packagePath,
761
+ mode: publish.mode,
762
+ registry: publish.registry,
763
+ "dist-tag": publish.distTag,
764
+ ...(publish.mode === "npm" ? { "token-env-name": "NODE_AUTH_TOKEN", provenance } : {}),
765
+ ...(publish.mode === "github-packages" ? { "token-env-name": "GITHUB_TOKEN" } : {}),
766
+ ...(publish.mode === "github-release" ? { "notes-file": ".async/release/release-notes.md" } : {})
767
+ }, "publish", {
768
+ permissions: {
769
+ ...(publish.mode === "github-packages" || publish.mode === "github-release" ? { packages: "write", contents: "write" } : {}),
770
+ ...(provenance ? { "id-token": "write" } : {})
771
+ },
772
+ secrets: publish.mode === "npm" ? ["NODE_AUTH_TOKEN"] : ["GITHUB_TOKEN"],
773
+ networked: true,
774
+ dangerous: true
775
+ });
776
+ }
777
+ function commentManifestStep(id, name) {
778
+ return actionManifestStep(id, name, ASYNC_COMMENT_ACTION, {
779
+ mode: "pr-comment",
780
+ repository: "${{ github.repository }}",
781
+ number: "${{ github.event.pull_request.number }}",
782
+ marker: "${{ steps.source.outputs.comment-marker }}",
783
+ body: "${{ steps.source.outputs.comment-body }}"
784
+ }, "comment", { permissions: { issues: "write" }, secrets: ["GITHUB_TOKEN"], networked: true });
785
+ }
786
+ function pagesActionManifestStep(id, name, pages) {
787
+ return actionManifestStep(id, name, ASYNC_PAGES_ACTION, {
788
+ mode: pages.build.kind,
789
+ ...(pages.build.kind === "jekyll" ? { source: pages.build.source, destination: pages.build.destination ?? "./_site" } : {}),
790
+ ...(pages.build.kind === "static" ? { path: pages.build.path } : {}),
791
+ ...(pages.build.kind === "prerender" ? { path: pages.build.path, "validate-index": pages.build.validateIndex ?? true, "spa-fallback": pages.build.spaFallback ?? false } : {})
792
+ }, "pages", { permissions: { pages: "write", "id-token": "write" } });
793
+ }
794
+ function actionManifestStep(id, name, ref, input, contract, options = {}) {
795
+ const action = actionFromRef(ref);
796
+ return {
797
+ id: safeGeneratedJobId(id),
798
+ name,
799
+ uses: `${action.uses}@${action.sha}`,
800
+ label: action.label,
801
+ ...(options.if ? { if: options.if } : {}),
802
+ with: sortObject(input),
803
+ ...(options.env && Object.keys(options.env).length > 0 ? { env: options.env } : {}),
804
+ permissions: options.permissions ?? {},
805
+ secrets: [...new Set(options.secrets ?? [])].sort(),
806
+ local: {
807
+ contract,
808
+ mode: "action",
809
+ network: options.networked ? "mock" : "mock",
810
+ networked: options.networked ?? false,
811
+ dangerous: options.dangerous ?? false,
812
+ ...(options.networked ? { mockReason: "networked action is mocked unless --network allow is selected" } : {}),
813
+ ...(options.fallbackReason ? { fallbackReason: options.fallbackReason } : {})
814
+ }
815
+ };
816
+ }
817
+ function shellManifestStep(id, name, command, contract) {
818
+ return {
819
+ id: safeGeneratedJobId(id),
820
+ name,
821
+ run: command,
822
+ secrets: [],
823
+ permissions: {},
824
+ local: {
825
+ contract,
826
+ mode: "shell",
827
+ network: "mock",
828
+ networked: commandLooksNetworked(command),
829
+ dangerous: commandLooksDangerous(command),
830
+ ...(commandLooksNetworked(command) ? { mockReason: "networked shell command is simulated unless --network allow is selected" } : {})
831
+ }
832
+ };
833
+ }
834
+ function actionFromRef(ref) {
835
+ const found = GENERATED_ACTIONS.find((action) => action.ref === ref);
836
+ if (found)
837
+ return found;
838
+ const [usesPart, rest] = ref.split("@");
839
+ const sha = rest?.split(/\s+/u)[0] ?? "";
840
+ return { id: usesPart ?? ref, uses: usesPart ?? ref, sha, label: "", ref };
841
+ }
842
+ function manifestEnv(env) {
843
+ return Object.fromEntries(Object.entries(env)
844
+ .map(([name, value]) => [name, renderGitHubEnvValue(value)])
845
+ .filter((entry) => typeof entry[1] === "string")
846
+ .sort(([left], [right]) => left.localeCompare(right)));
847
+ }
848
+ function secretNamesFromEnv(env) {
849
+ const names = new Set();
850
+ for (const [envName, value] of Object.entries(env)) {
851
+ if (isSecretEnvValue(value)) {
852
+ names.add(typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" ? value.name : envName);
853
+ }
854
+ }
855
+ return [...names].sort();
856
+ }
857
+ function artifactsForSteps(jobId, steps, retentionDays, ifNoFilesFound) {
858
+ const artifacts = [];
859
+ for (const step of steps) {
860
+ const artifactName = typeof step.with?.["artifact-name"] === "string" ? step.with["artifact-name"] : undefined;
861
+ if (artifactName) {
862
+ artifacts.push({
863
+ name: artifactName,
864
+ path: artifactPathForContract(step.local.contract),
865
+ mode: step.local.contract === "evidence" ? "upload" : "local",
866
+ producerJob: jobId,
867
+ retentionDays,
868
+ ifNoFilesFound
869
+ });
870
+ }
871
+ }
872
+ return artifacts.sort((left, right) => left.name.localeCompare(right.name));
873
+ }
874
+ function artifactPathForContract(contract) {
875
+ if (contract === "evidence")
876
+ return ".async/evidence";
877
+ if (contract === "agent-evidence")
878
+ return ".async/actions/agent-evidence";
879
+ if (contract === "pages")
880
+ return ".async/pages";
881
+ return ".async/runs";
882
+ }
883
+ function buildGeneratedPagesManifest(model, rendered, event, network) {
884
+ const pages = model.pages;
885
+ const steps = [
886
+ checkoutStep(),
887
+ ...setupManifestSteps(model),
888
+ ...dependencyInstallManifestSteps(model),
889
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
890
+ ...taskCacheManifestSteps(model, { kind: "task", id: pages.target ?? "docs.site" }),
891
+ runActionManifestStep("run-pages-target", "Run Pages target", `${model.command} github check && ${model.command} run-task ${shellWord(pages.target ?? "docs.site")}`, {}, "run"),
892
+ ...taskCacheSaveManifestSteps(model, { kind: "task", id: pages.target ?? "docs.site" }),
893
+ pagesActionManifestStep("upload-pages-artifact", "Upload Pages artifact", pages),
894
+ ...evidenceCollectManifestSteps(model)
895
+ ];
896
+ return makeJobManifest(model, rendered, event, {
897
+ id: pages.job,
898
+ kind: "generated",
899
+ target: pages.target ? [pages.target] : [],
900
+ runsOn: "ubuntu-latest",
901
+ permissions: { contents: "read", pages: "write", "id-token": "write" },
902
+ environment: pages.environment ?? null,
903
+ concurrency: null,
904
+ if: renderGeneratedPagesCondition(pages),
905
+ trigger: ["pull_request", "push", "workflow_dispatch"],
906
+ steps,
907
+ network
908
+ });
909
+ }
910
+ function buildPackagePreviewManifest(model, rendered, event, network) {
911
+ const preview = model.packagePreviews;
912
+ const steps = [
913
+ checkoutStep(),
914
+ ...setupManifestSteps(model),
915
+ ...dependencyInstallManifestSteps(model),
916
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
917
+ ...taskCacheManifestSteps(model, { kind: "task", id: preview.target ?? "pack", manifestPath: ".async/actions/cache/package-preview-cache-manifest.json" }),
918
+ runActionManifestStep("run-package-preview-target", "Run package preview target", `${model.command} github check && ${model.command} run-task ${shellWord(preview.target ?? "pack")}`, {}, "run"),
919
+ ...taskCacheSaveManifestSteps(model, { kind: "task", id: preview.target ?? "pack", manifestPath: ".async/actions/cache/package-preview-cache-manifest.json" }),
920
+ actionManifestStep("publish-package-preview", "Publish package preview", ASYNC_PREVIEW_ACTION, {
921
+ "package-path": preview.package ?? ".",
922
+ "target-registry": preview.registry,
923
+ ...(preview.namespace ? { namespace: preview.namespace } : {}),
924
+ mode: "pr",
925
+ comment: preview.comment,
926
+ "token-env-name": preview.tokenEnv
927
+ }, "preview", { permissions: { packages: "write" }, secrets: [preview.tokenEnv], networked: true, dangerous: true }),
928
+ ...(preview.comment ? [commentManifestStep("comment-package-preview", "Comment package preview")] : []),
929
+ ...evidenceCollectManifestSteps(model)
930
+ ];
931
+ return makeJobManifest(model, rendered, event, {
932
+ id: "package-preview",
933
+ kind: "generated",
934
+ target: preview.target ? [preview.target] : [],
935
+ runsOn: "ubuntu-latest",
936
+ permissions: { contents: "read", issues: "write", packages: "write", "pull-requests": "write" },
937
+ environment: null,
938
+ concurrency: null,
939
+ if: "github.event_name == 'pull_request' && github.event.pull_request.draft == false",
940
+ trigger: ["pull_request"],
941
+ steps,
942
+ network
943
+ });
944
+ }
945
+ function buildBridgeManifest(model, rendered, event, network) {
946
+ const bridge = model.bridge;
947
+ const command = [
948
+ "npx",
949
+ "--yes",
950
+ `@async/github-app@${bridge.packageVersion}`,
951
+ "actions",
952
+ "pull",
953
+ "--branch-prefix",
954
+ bridge.branchPrefix,
955
+ "--pull-request",
956
+ String(bridge.pullRequest),
957
+ ...bridge.allowedPaths.flatMap((path) => ["--allowed-path", path])
958
+ ].map(shellWord).join(" ");
959
+ const steps = [
960
+ checkoutStep(),
961
+ ...setupManifestSteps(model),
962
+ ...dependencyInstallManifestSteps(model),
963
+ ...(model.buildCommand ? [shellManifestStep("build-pipeline-cli", "Build pipeline CLI", model.buildCommand, "build")] : []),
964
+ runActionManifestStep("check-generated-workflow", "Check generated workflow", `${model.command} github check`, {}, "run"),
965
+ runActionManifestStep("pull-and-apply-async-bridge-change-sets", "Pull and apply Async bridge change sets", command, {
966
+ ASYNC_PROJECT_URL: { kind: "async-pipeline.env.var", name: bridge.endpointVar },
967
+ ASYNC_PROJECT_TOKEN: { kind: "async-pipeline.env.secret", name: bridge.tokenEnv },
968
+ GITHUB_TOKEN: { kind: "async-pipeline.env.secret", name: "GITHUB_TOKEN" }
969
+ }, "storage-bridge"),
970
+ ...evidenceCollectManifestSteps(model)
971
+ ];
972
+ return makeJobManifest(model, rendered, event, {
973
+ id: bridge.job,
974
+ kind: "generated",
975
+ target: [],
976
+ runsOn: "ubuntu-latest",
977
+ permissions: { contents: "write", "pull-requests": "write" },
978
+ environment: null,
979
+ concurrency: "async-bridge-${{ github.repository }}",
980
+ if: renderBridgeCondition(bridge),
981
+ trigger: ["schedule", "workflow_dispatch"],
982
+ steps,
983
+ network
984
+ });
985
+ }
986
+ function buildEvidenceFanInManifest(model, rendered, event, network) {
987
+ return makeJobManifest(model, rendered, event, {
988
+ id: model.evidence.job,
989
+ kind: "generated",
990
+ target: [],
991
+ runsOn: "ubuntu-latest",
992
+ permissions: { contents: "read" },
993
+ environment: null,
994
+ concurrency: null,
995
+ if: "always()",
996
+ trigger: ["fan-in"],
997
+ steps: [
998
+ actionManifestStep("merge-evidence-manifests", "Merge evidence manifests", ASYNC_EVIDENCE_ACTION, {
999
+ mode: "merge",
1000
+ "artifact-pattern": `${model.evidence.artifactNamePrefix}-*`,
1001
+ "manifest-path": ".async/evidence/index.json",
1002
+ "summary-path": ".async/evidence/index.md",
1003
+ "artifact-name": `${model.evidence.artifactNamePrefix}-index`,
1004
+ "retention-days": model.evidence.retentionDays,
1005
+ "if-no-files-found": model.evidence.ifNoFilesFound,
1006
+ "include-summary": model.evidence.includeSummary
1007
+ }, "evidence")
1008
+ ],
1009
+ network
1010
+ });
1011
+ }
1012
+ function buildDependabotManifest(model, rendered, event, network) {
1013
+ return makeJobManifest(model, rendered, event, {
1014
+ id: "dependabot-auto-merge",
1015
+ kind: "generated",
1016
+ target: [],
1017
+ runsOn: "ubuntu-latest",
1018
+ permissions: { contents: "write", "pull-requests": "write" },
1019
+ environment: null,
1020
+ concurrency: null,
1021
+ if: "github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.draft == false",
1022
+ trigger: ["pull_request_target"],
1023
+ steps: [
1024
+ actionManifestStep("fetch-dependabot-metadata", "Fetch Dependabot metadata", DEPENDABOT_FETCH_METADATA_ACTION, { "github-token": "${{ secrets.GITHUB_TOKEN }}" }, "dependabot", { secrets: ["GITHUB_TOKEN"], networked: true }),
1025
+ 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 })
1026
+ ],
1027
+ network
1028
+ });
1029
+ }
1030
+ function buildSourceImpactPlanManifest(model, rendered, event, sourceJob, network) {
1031
+ return makeJobManifest(model, rendered, event, {
1032
+ id: sourceJob.planJob,
1033
+ kind: "generated",
1034
+ target: [],
1035
+ runsOn: sourceJob.github?.runsOn ?? "ubuntu-latest",
1036
+ permissions: { contents: "read" },
1037
+ environment: null,
1038
+ concurrency: null,
1039
+ if: sourceJob.if ?? null,
1040
+ trigger: ["source-impact"],
1041
+ steps: [
1042
+ checkoutStep(),
1043
+ ...setupManifestSteps(model),
1044
+ shellManifestStep("write-generated-source-plan", "Write generated source plan", `write ${sourceJob.planPath}`, "source-impact"),
1045
+ actionManifestStep("plan-source-impact-matrix", "Plan source impact matrix", ASYNC_SOURCE_IMPACT_ACTION, {
1046
+ mode: "matrix",
1047
+ "plan-path": sourceJob.planPath,
1048
+ "matrix-path": `.async/actions/source-impact/${safeArtifactPart(sourceJob.job)}-matrix.json`
1049
+ }, "source-impact")
1050
+ ],
1051
+ network
1052
+ });
1053
+ }
1054
+ function buildSourceImpactMatrixManifest(model, rendered, event, sourceJob, network) {
1055
+ return makeJobManifest(model, rendered, event, {
1056
+ id: sourceJob.matrixJob,
1057
+ kind: "generated",
1058
+ target: [sourceJob.job],
1059
+ runsOn: sourceJob.github?.runsOn ?? "ubuntu-latest",
1060
+ matrix: sourceJob.plan.matrix.include.map((entry, index) => ({ runner: [String(entry.source)], index })),
1061
+ permissions: { contents: "read" },
1062
+ environment: null,
1063
+ concurrency: null,
1064
+ if: sourceJob.if ?? null,
1065
+ trigger: ["source-impact"],
1066
+ steps: [
1067
+ checkoutStep(),
1068
+ ...setupManifestSteps(model),
1069
+ actionManifestStep("validate-source-impact-row", "Validate source impact row", ASYNC_SOURCE_IMPACT_ACTION, {
1070
+ mode: "validate",
1071
+ source: "${{ matrix.source }}"
1072
+ }, "source-impact"),
1073
+ runActionManifestStep("run-source-impact-task", "Run source impact task", `${model.command} github check && ${model.command} run-task ${shellWord(sourceJob.job)}`, sourceJob.env, "run"),
1074
+ ...evidenceCollectManifestSteps(model)
1075
+ ],
1076
+ network
1077
+ });
1078
+ }
1079
+ function generatedPagesSelected(pages, event) {
1080
+ if (event.name === "pull_request")
1081
+ return pages.triggers.pullRequest;
1082
+ if (event.name === "push")
1083
+ return Boolean(pages.triggers.main);
1084
+ return event.name === "workflow_dispatch" && event.selectedJob === pages.job;
1085
+ }
1086
+ function bridgeSelected(bridge, event) {
1087
+ if (event.name === "schedule")
1088
+ return Boolean(bridge.schedule && (!event.schedule || event.schedule === bridge.schedule));
1089
+ return event.name === "workflow_dispatch" && event.selectedJob === bridge.job;
1090
+ }
1091
+ function skipReasonForJob(event, trigger) {
1092
+ if (event.name === "workflow_dispatch" && !event.selectedJob && trigger.some((id) => id === "manual"))
1093
+ return "manual_selector_missing";
1094
+ return "event_filter";
1095
+ }
1096
+ function skipReasonForGeneratedJob(event, manualIds) {
1097
+ if (event.name === "workflow_dispatch" && !event.selectedJob && manualIds.length > 0)
1098
+ return "manual_selector_missing";
1099
+ return "event_filter";
1100
+ }
1101
+ function validateLocalStep(manifest, step, env) {
1102
+ const issues = [];
1103
+ for (const [permission, required] of Object.entries(step.permissions ?? {})) {
1104
+ const actual = manifest.job.permissions[permission];
1105
+ if (!permissionAllows(actual, required)) {
1106
+ issues.push(`requires ${permission}: ${required}, but job grants ${actual ?? "none"}`);
1107
+ }
1108
+ }
1109
+ if (step.local.networked && manifest.local.network === "deny") {
1110
+ issues.push("networked step is denied by --network deny");
1111
+ }
1112
+ if (step.local.networked && manifest.local.network === "allow") {
1113
+ for (const secret of step.secrets) {
1114
+ if (!env[secret])
1115
+ issues.push(`requires secret env ${secret} for --network allow`);
1116
+ }
1117
+ }
1118
+ return issues;
1119
+ }
1120
+ function permissionAllows(actual, required) {
1121
+ if (required === "read")
1122
+ return actual === "read" || actual === "write";
1123
+ return actual === "write";
1124
+ }
1125
+ function commandLooksNetworked(command) {
1126
+ return /\b(?:gh|npm\s+publish|npx|curl|wget|git\s+(?:push|fetch|pull|clone))\b/u.test(command);
1127
+ }
1128
+ function commandLooksDangerous(command) {
1129
+ return /\b(?:publish|push|release|comment|merge|pull-request)\b/u.test(command);
1130
+ }
244
1131
  function buildRenderModel(pipeline, options) {
245
1132
  const usedTriggerIds = new Set(Object.values(pipeline.jobs).flatMap((job) => job.trigger));
246
1133
  const usedTriggers = Object.fromEntries([...usedTriggerIds].sort().map((triggerId) => [triggerId, pipeline.triggers[triggerId]]));