@intentius/chant-lexicon-github 0.124.57 → 0.124.59

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.
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EG;AAEH,OAAO,EAaL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAmB9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,4DAA4D;AAC5D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,EAC/B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;CAAO,GAC7C;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAgGpI;AAoID,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2EG;AAEH,OAAO,EAaL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAmB9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,4DAA4D;AAC5D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,EAC/B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;CAAO,GAC7C;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAiGpI;AAoID,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
@@ -98,7 +98,8 @@ export function buildGithubOpWavesDoc(spec, options, dialect = {}) {
98
98
  const forge = dialect.forge ?? "github";
99
99
  const product = resolveProductWords(options.words ?? {}).product;
100
100
  const image = options.image ?? OP_WAVES_IMAGE;
101
- const byReview = spec.waves.some((w) => w.approval === "pr-review");
101
+ // `approvals.preview` reads the merged pull request from the forge, as a pr-review wave does.
102
+ const byReview = spec.approvals?.preview === true || spec.waves.some((w) => w.approval === "pr-review");
102
103
  // #4630: a pr-review or pull-request wave gets the plans workflow and its approval status.
103
104
  const statusBeforeMerge = opWavesPostsApprovalStatus(spec);
104
105
  for (const wave of spec.waves) {
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA2CjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAkoBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA6CjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AA6qBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
@@ -61,7 +61,7 @@ import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/ga
61
61
  import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER, githubRunsOn } from "@intentius/chant/ci-jobs";
62
62
  import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
63
63
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
64
- import { prApplyComment, GREEN_TAG_PATTERN, GREEN_TICK_WORKFLOW, PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, PR_LOCK_JOB, PR_UNLOCK_JOB, prUnlockCommand, prLockCommand, prCommentLockJobs, prCommentPreviewJob, PREVIEW_REF_ENV, PREVIEW_HEAD_OUTPUT, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, prLoopJobStage, prStaleJob, PR_STALE_JOB, prLoopRunner, PR_ENV_REPORT_DIR, PR_ENV_SWEEP_CRON, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
64
+ import { prApplyComment, GREEN_TAG_PATTERN, GREEN_TICK_WORKFLOW, PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, PR_LOCK_JOB, PR_UNLOCK_JOB, prUnlockCommand, prLockCommand, prCommentLockJobs, prCommentPreviewJob, prActionJob, prLoopPassedEnv, PREVIEW_REF_ENV, PREVIEW_HEAD_OUTPUT, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, prLoopJobStage, prStaleJob, PR_STALE_JOB, prLoopRunner, PR_ENV_REPORT_DIR, PR_ENV_SWEEP_CRON, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
65
65
  import { forgejoRepoLockLines, REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
66
66
  import { memberRepoPath } from "@intentius/chant/lexicon";
67
67
  import { productStatusContext, resolveProductWords } from "@intentius/chant/ci-product-words";
@@ -239,7 +239,10 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
239
239
  ];
240
240
  // #4168 step 9: under apply on merge the apply runs on a green signal, and a push to the branch records.
241
241
  const forgejo = loop.forge === "forgejo";
242
- const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : "github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'";
242
+ // #4654: the action job answers its comment and a dispatch that names an action; such a dispatch starts no apply.
243
+ const action = prActionJob(loop.forge ?? "github", loop);
244
+ const plainDispatch = action ? `(github.event_name == 'workflow_dispatch' && github.event.inputs.${action.inputs.action} == '')` : "github.event_name == 'workflow_dispatch'";
245
+ const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : `github.event_name == 'workflow_run' || ${plainDispatch}`;
243
246
  const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
244
247
  // #4301: a `/chant apply` comment on a pull request starts the apply before merge. GitHub also asks for a commenter who can write.
245
248
  const onComment = [
@@ -467,6 +470,34 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
467
470
  },
468
471
  }
469
472
  : {};
473
+ // #4654: `/<product> <action word> <member> <action>`, or a dispatch naming both, runs the product's action verb at the target branch's commit.
474
+ const actionJobs = action
475
+ ? {
476
+ [action.jobName]: {
477
+ if: [
478
+ `(github.event_name == 'workflow_dispatch' && github.event.inputs.${action.inputs.action} != '')`,
479
+ `(${[
480
+ "github.event_name == 'issue_comment'",
481
+ `startsWith(github.event.comment.body, '${action.comment.replace(/'/g, "''")} ')`,
482
+ ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
483
+ ].join(" && ")})`,
484
+ ].join(" || "),
485
+ "runs-on": GITHUB_RUNNER,
486
+ container: image,
487
+ // One action of a pipeline at a time, and never two runs of one request.
488
+ concurrency: concurrency(`chant-action-${env}${member ? `-${member.name}` : ""}`),
489
+ permissions: withIdToken({ contents: "write", issues: "write", "pull-requests": "write" }),
490
+ steps: [
491
+ ...(identity ? [tradeStep(identity.apply, "apply")] : []),
492
+ // An issue_comment run checks out the target branch's commit, a dispatch the ref it names: never a pull request's head.
493
+ { name: "Check out the target branch's commit", uses: CHECKOUT, with: { "fetch-depth": 0 } },
494
+ ...runSteps(prLoopSetup(image, loop.engineSetup)),
495
+ ...runSteps(beforeScript),
496
+ { name: "Run the requested action under its gated request", env: { ...prLoopPassedEnv(forgeName, loop), ...action.env }, run: action.script },
497
+ ],
498
+ },
499
+ }
500
+ : {};
470
501
  const jobsDoc = {
471
502
  plan: {
472
503
  if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
@@ -488,6 +519,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
488
519
  ...lockJobs,
489
520
  ...commentLockJobs,
490
521
  ...previewJobs,
522
+ ...actionJobs,
491
523
  ...staleJobs,
492
524
  ...Object.fromEntries(applyJobs),
493
525
  ...Object.fromEntries(controlJobs),
@@ -506,8 +538,18 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
506
538
  on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
507
539
  }
508
540
  // #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
509
- if (loop.applyBeforeMerge || commentLocks.length > 0 || preview)
541
+ if (loop.applyBeforeMerge || commentLocks.length > 0 || preview || action)
510
542
  on.issue_comment = { types: ["created"] };
543
+ // #4654: a dispatch names a member and an action; both empty, it is the dispatch it was before.
544
+ if (action) {
545
+ const input = (description) => ({ description, required: false, type: "string", default: "" });
546
+ on.workflow_dispatch = {
547
+ inputs: {
548
+ [action.inputs.member]: input(`The ${resolveProductWords(loop.words).noun[0]} the action runs on`),
549
+ [action.inputs.action]: input("The action to run, by its name"),
550
+ },
551
+ };
552
+ }
511
553
  // #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
512
554
  if (loop.runners) {
513
555
  for (const [name, job] of Object.entries(jobsDoc)) {
@@ -521,7 +563,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
521
563
  ...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
522
564
  env: { ...options.variables, CHANT_ENV: env },
523
565
  jobsDoc,
524
- stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
566
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(action ? [action.jobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
525
567
  jobs: [
526
568
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
527
569
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
@@ -539,6 +581,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
539
581
  { jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
540
582
  ]
541
583
  : []),
584
+ ...(action ? [{ jobName: action.jobName, component: "day-2 action", stage: action.jobName, needs: [] }] : []),
542
585
  ...applyNames.map((name, i) => ({
543
586
  jobName: name,
544
587
  component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "eab8cbe3ae719023202bcc5ffdbbbd5ca8dd0d263d22ca405024ff9991cf07b6",
4
+ "manifest.json": "4066803d139ed6d48a742aaf7871d082dfbd84e20e3b0ce47759310f71ed6bd6",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
7
7
  "rules/deprecated-action-version.ts": "a9ba7cc924ff1c7055110416d8989273690415bfb5e8dcc32dc9431b9351f5ca",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "c581b6a6d25abd873cb8aba19d5ccd648c178312acdbdac07a30900b9b685b5a"
81
+ "composite": "ae39d39910bc91feedb80f2e03a55d960e100010e9afbf71ff3458d0f3491d49"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.57",
3
+ "version": "0.124.59",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.124.57",
3
+ "version": "0.124.59",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,6 +61,6 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.124.57"
64
+ "@intentius/chant": "^0.124.59"
65
65
  }
66
66
  }
@@ -0,0 +1,228 @@
1
+ name: chant-pr-prod
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+ push:
8
+ branches:
9
+ - main
10
+ workflow_run:
11
+ workflows:
12
+ - chant-ci-green
13
+ types:
14
+ - completed
15
+ workflow_dispatch:
16
+ inputs:
17
+ stack:
18
+ description: The stack the action runs on
19
+ required: false
20
+ type: string
21
+ default: ''
22
+ action:
23
+ description: The action to run, by its name
24
+ required: false
25
+ type: string
26
+ default: ''
27
+ issue_comment:
28
+ types:
29
+ - created
30
+
31
+ env:
32
+ CHANT_ENV: prod
33
+
34
+ jobs:
35
+ plan:
36
+ if: github.event_name == 'pull_request'
37
+ runs-on: ubuntu-latest
38
+ container: node:22
39
+ permissions:
40
+ contents: read
41
+ outputs:
42
+ report_0: '${{ steps.plan.outputs.report_0 }}'
43
+ steps:
44
+ - name: Check out the change
45
+ env:
46
+ CHANT_CHECKOUT_TOKEN: '${{ github.token }}'
47
+ run: |
48
+ set -eu
49
+ git config --global --add safe.directory "$PWD" 2>/dev/null || true
50
+ [ -d .git ] || git init -q .
51
+ git remote get-url origin >/dev/null 2>&1 || git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
52
+ if [ -n "${CHANT_CHECKOUT_TOKEN:-}" ]; then
53
+ chant_auth="$(printf 'x-access-token:%s' "$CHANT_CHECKOUT_TOKEN" | base64 | tr -d '\n')"
54
+ export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraheader GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $chant_auth"
55
+ unset chant_auth
56
+ fi
57
+ ref="$GITHUB_REF"
58
+ want="$GITHUB_SHA"
59
+ case "$ref" in
60
+ refs/heads/*) target="refs/remotes/origin/${ref#refs/heads/}" ;;
61
+ refs/tags/*) target="$ref" ;;
62
+ refs/*) target="refs/remotes/${ref#refs/}" ;;
63
+ *) target="refs/remotes/origin/chant-head" ;;
64
+ esac
65
+ if git rev-parse -q --verify HEAD >/dev/null; then git clean -ffdxq; fi
66
+ git -c protocol.version=2 fetch -q --prune --no-recurse-submodules origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' "+$want:$target"
67
+ git -c protocol.version=2 fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true
68
+ case "$ref" in
69
+ refs/heads/*) git checkout -q --force -B "${ref#refs/heads/}" "$target" ;;
70
+ *) git checkout -q --force --detach "$target" ;;
71
+ esac
72
+ - run: |
73
+ if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
74
+ command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
75
+ - name: Plan the members this pull request reaches
76
+ id: plan
77
+ env:
78
+ BASE_SHA: '${{ github.event.pull_request.base.sha }}'
79
+ PR_NUMBER: '${{ github.event.pull_request.number }}'
80
+ run: |
81
+ if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
82
+ chant_report="$PWD/.chant/pr"
83
+ chant_handover() { node -e 'const fs=require("fs"),p=require("path"),z=require("zlib");const [dir,out]=process.argv.slice(1);const CHUNK=450000,PARTS=1,NOTE=["pr-plan.json","pr-note.md"];const all={};const walk=(d,r)=>{let n=[];try{n=fs.readdirSync(d).sort()}catch{return}for(const x of n){const f=p.join(d,x),k=r?r+"/"+x:x;let s;try{s=fs.lstatSync(f)}catch{continue}if(s.isDirectory())walk(f,k);else if(s.isFile())all[k]=fs.readFileSync(f).toString("base64")}};walk(dir,"");const pack=(o)=>z.gzipSync(JSON.stringify(o)).toString("base64");let b=pack(all);if(b.length>CHUNK*PARTS){b=pack(Object.fromEntries(Object.entries(all).filter(([k])=>NOTE.includes(k))));if(b.length>CHUNK*PARTS){b=pack({});console.error("chant: the plan report is too large to hand to the note job, even cut to the report and the note; it hands on none of it")}else console.error("chant: the plan report is too large to hand to the note job whole; it hands on pr-plan.json and pr-note.md")}let o="";for(let i=0;i*CHUNK<b.length;i++)o+="report_"+i+"="+b.slice(i*CHUNK,(i+1)*CHUNK)+"\n";fs.appendFileSync(out,o);' "$chant_report" "${GITHUB_OUTPUT:-/dev/null}" || echo "chant: the plan report could not be handed to the note job" >&2; }
84
+ trap chant_handover EXIT
85
+ npx --no-install plywood ci run plan --chant "npx --no-install plywood" --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github
86
+ plan-note:
87
+ needs:
88
+ - plan
89
+ if: always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')
90
+ runs-on: ubuntu-latest
91
+ container: node:22
92
+ permissions:
93
+ actions: read
94
+ contents: read
95
+ pull-requests: write
96
+ statuses: write
97
+ steps:
98
+ - name: Check out the target branch's commit
99
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
100
+ with:
101
+ ref: '${{ github.event.pull_request.base.sha }}'
102
+ - name: Take the plan report from the plan job
103
+ run: |
104
+ set -u
105
+ node -e 'const fs=require("fs"),p=require("path"),z=require("zlib");const dir=process.argv[1];let b="";for(const l of fs.readFileSync(0,"utf8").split("\n")){if(!l.trim())continue;let s="";try{s=JSON.parse(l)}catch{}if(typeof s==="string")b+=s}if(!b){console.log("chant: the plan job handed on no report");process.exit(0)}let files;try{files=JSON.parse(z.gunzipSync(Buffer.from(b,"base64"),{maxOutputLength:268435456}).toString("utf8"))}catch(e){console.log("chant: the report the plan job handed on does not read: "+e.message);process.exit(0)}if(!files||typeof files!=="object"||Array.isArray(files)){console.log("chant: the report the plan job handed on is not a set of files");process.exit(0)}let n=0;for(const [k,v] of Object.entries(files)){const parts=k.split("/");if(typeof v!=="string"||k.length>1024||parts.some((x)=>!x||x==="."||x===".."||/[\\\x00-\x1f]/.test(x))){console.log("chant: left out "+JSON.stringify(k));continue}try{const f=p.join(dir,...parts);fs.mkdirSync(p.dirname(f),{recursive:true});fs.writeFileSync(f,Buffer.from(v,"base64"));n++}catch(e){console.log("chant: could not write "+JSON.stringify(k)+": "+e.message)}}console.log("chant: took "+n+" report files from the plan job")' .chant/pr <<'END_OF_REPORT'
106
+ ${{ toJSON(needs.plan.outputs.report_0) }}
107
+ END_OF_REPORT
108
+ - name: Keep the plan report
109
+ if: always()
110
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
111
+ with:
112
+ name: chant-pr-plan
113
+ path: .chant/pr
114
+ if-no-files-found: ignore
115
+ include-hidden-files: true
116
+ - name: Post the plan note and plywood/plan
117
+ if: always()
118
+ env:
119
+ PR_NUMBER: '${{ github.event.pull_request.number }}'
120
+ HEAD_SHA: '${{ github.sha }}'
121
+ PLAN_RESULT: '${{ needs.plan.result }}'
122
+ GITHUB_TOKEN: '${{ github.token }}'
123
+ run: npx --no-install plywood components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge github --result "$PLAN_RESULT"
124
+ action:
125
+ if: (github.event_name == 'workflow_dispatch' && github.event.inputs.action != '') || (github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/plywood action ') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
126
+ runs-on: ubuntu-latest
127
+ container: node:22
128
+ concurrency:
129
+ group: chant-action-prod
130
+ cancel-in-progress: false
131
+ permissions:
132
+ contents: write
133
+ issues: write
134
+ pull-requests: write
135
+ steps:
136
+ - name: Check out the target branch's commit
137
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
138
+ with:
139
+ fetch-depth: 0
140
+ - run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
141
+ - name: Run the requested action under its gated request
142
+ env:
143
+ CHANT_ACTION_COMMENT: '${{ github.event.comment.body }}'
144
+ CHANT_ACTION_MEMBER: '${{ github.event.inputs.stack }}'
145
+ CHANT_ACTION: '${{ github.event.inputs.action }}'
146
+ PR_NUMBER: '${{ github.event.issue.number }}'
147
+ GITHUB_TOKEN: '${{ github.token }}'
148
+ run: |
149
+ set +e
150
+ npx --no-install plywood action --from-event
151
+ code="$?"
152
+ if [ "$code" -eq 3 ]; then echo "The action waits for an approval."; exit 0; fi
153
+ exit "$code"
154
+ record:
155
+ if: github.event_name == 'push'
156
+ runs-on: ubuntu-latest
157
+ container: node:22
158
+ concurrency:
159
+ group: chant-record-prod
160
+ cancel-in-progress: false
161
+ permissions:
162
+ contents: write
163
+ pull-requests: write
164
+ statuses: write
165
+ steps:
166
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
167
+ with:
168
+ fetch-depth: 0
169
+ - run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
170
+ - name: Record the plan on the merged commit; nothing applies
171
+ env:
172
+ BASE_SHA: '${{ github.event.before }}'
173
+ GITHUB_TOKEN: '${{ github.token }}'
174
+ CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
175
+ run: |
176
+ if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
177
+ npx --no-install plywood ci run record --chant "npx --no-install plywood" --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github
178
+ - name: Keep the record report
179
+ if: always()
180
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
181
+ with:
182
+ name: chant-pr-record
183
+ path: .chant/pr
184
+ if-no-files-found: ignore
185
+ include-hidden-files: true
186
+ apply:
187
+ if: github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == '')
188
+ runs-on: ubuntu-latest
189
+ container: node:22
190
+ concurrency:
191
+ group: chant-apply-prod
192
+ cancel-in-progress: false
193
+ permissions:
194
+ contents: write
195
+ pull-requests: write
196
+ statuses: write
197
+ steps:
198
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
199
+ with:
200
+ fetch-depth: 0
201
+ - run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
202
+ - name: Restore the record of an earlier attempt at this commit
203
+ uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
204
+ with:
205
+ path: .chant/pr-resume/pr-apply.json
206
+ key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
207
+ restore-keys: chant-apply-prod-${{ github.sha }}-
208
+ - name: Apply the newest green commit under the merged changes' approvals
209
+ env:
210
+ GITHUB_TOKEN: '${{ github.token }}'
211
+ CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
212
+ run: |
213
+ git fetch --quiet --tags origin "+refs/heads/main:refs/remotes/origin/main" && GREEN_SHA="$(npx --no-install plywood ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"
214
+ npx --no-install plywood ci run apply --chant "npx --no-install plywood" --in-place --env prod --gate pr-apply --forge github
215
+ - name: Keep the record for a re-run
216
+ if: always()
217
+ uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
218
+ with:
219
+ path: .chant/pr-resume/pr-apply.json
220
+ key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
221
+ - name: Keep the apply report
222
+ if: always()
223
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
224
+ with:
225
+ name: chant-pr-apply
226
+ path: .chant/pr
227
+ if-no-files-found: ignore
228
+ include-hidden-files: true
@@ -60,6 +60,13 @@ describe("generateGithubOpWavesPipeline", () => {
60
60
  expect(files[0]!.yaml).toMatch(/permissions:\n\s+contents: write\n\s+pull-requests: read/);
61
61
  });
62
62
 
63
+ test("approvals.preview records the head's plans and lets the wave read the merged pull request", () => {
64
+ const { files } = generateGithubOpWavesPipeline({ ...spec, approvals: { preview: true } }, { specFile: "waves.json" });
65
+ expect(files.map((f) => f.name)).toEqual([`${spec.name}.yml`, `${spec.name}-plans.yml`]);
66
+ expect(files[1]!.yaml).toContain("run: chant run wave --spec waves.json --record-plans");
67
+ expect(files[0]!.yaml).toMatch(/permissions:\n\s+contents: write\n\s+pull-requests: read/);
68
+ });
69
+
63
70
  test("a wave-level plan and apply, and stage hooks, render the same jobs: chant run wave runs them (#3762)", () => {
64
71
  const { plan: _plan, ...rest } = spec;
65
72
  const waveLevel: OpWavesSpec = {
@@ -135,7 +135,8 @@ export function buildGithubOpWavesDoc(
135
135
  const forge = dialect.forge ?? "github";
136
136
  const product = resolveProductWords(options.words ?? {}).product;
137
137
  const image = options.image ?? OP_WAVES_IMAGE;
138
- const byReview = spec.waves.some((w) => w.approval === "pr-review");
138
+ // `approvals.preview` reads the merged pull request from the forge, as a pr-review wave does.
139
+ const byReview = spec.approvals?.preview === true || spec.waves.some((w) => w.approval === "pr-review");
139
140
  // #4630: a pr-review or pull-request wave gets the plans workflow and its approval status.
140
141
  const statusBeforeMerge = opWavesPostsApprovalStatus(spec);
141
142
  for (const wave of spec.waves) {
@@ -82,6 +82,8 @@ import {
82
82
  prLockCommand,
83
83
  prCommentLockJobs,
84
84
  prCommentPreviewJob,
85
+ prActionJob,
86
+ prLoopPassedEnv,
85
87
  PREVIEW_REF_ENV,
86
88
  PREVIEW_HEAD_OUTPUT,
87
89
  prLoopWaveJobs,
@@ -336,7 +338,10 @@ function prLoopGithubDoc(
336
338
  ];
337
339
  // #4168 step 9: under apply on merge the apply runs on a green signal, and a push to the branch records.
338
340
  const forgejo = loop.forge === "forgejo";
339
- const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : "github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'";
341
+ // #4654: the action job answers its comment and a dispatch that names an action; such a dispatch starts no apply.
342
+ const action = prActionJob(loop.forge ?? "github", loop);
343
+ const plainDispatch = action ? `(github.event_name == 'workflow_dispatch' && github.event.inputs.${action.inputs.action} == '')` : "github.event_name == 'workflow_dispatch'";
344
+ const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : `github.event_name == 'workflow_run' || ${plainDispatch}`;
340
345
  const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
341
346
  // #4301: a `/chant apply` comment on a pull request starts the apply before merge. GitHub also asks for a commenter who can write.
342
347
  const onComment = [
@@ -566,6 +571,34 @@ function prLoopGithubDoc(
566
571
  },
567
572
  }
568
573
  : {};
574
+ // #4654: `/<product> <action word> <member> <action>`, or a dispatch naming both, runs the product's action verb at the target branch's commit.
575
+ const actionJobs: Record<string, unknown> = action
576
+ ? {
577
+ [action.jobName]: {
578
+ if: [
579
+ `(github.event_name == 'workflow_dispatch' && github.event.inputs.${action.inputs.action} != '')`,
580
+ `(${[
581
+ "github.event_name == 'issue_comment'",
582
+ `startsWith(github.event.comment.body, '${action.comment.replace(/'/g, "''")} ')`,
583
+ ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
584
+ ].join(" && ")})`,
585
+ ].join(" || "),
586
+ "runs-on": GITHUB_RUNNER,
587
+ container: image,
588
+ // One action of a pipeline at a time, and never two runs of one request.
589
+ concurrency: concurrency(`chant-action-${env}${member ? `-${member.name}` : ""}`),
590
+ permissions: withIdToken({ contents: "write", issues: "write", "pull-requests": "write" }),
591
+ steps: [
592
+ ...(identity ? [tradeStep(identity.apply, "apply")] : []),
593
+ // An issue_comment run checks out the target branch's commit, a dispatch the ref it names: never a pull request's head.
594
+ { name: "Check out the target branch's commit", uses: CHECKOUT, with: { "fetch-depth": 0 } },
595
+ ...runSteps(prLoopSetup(image, loop.engineSetup)),
596
+ ...runSteps(beforeScript),
597
+ { name: "Run the requested action under its gated request", env: { ...prLoopPassedEnv(forgeName, loop), ...action.env }, run: action.script },
598
+ ],
599
+ },
600
+ }
601
+ : {};
569
602
  const jobsDoc: Record<string, unknown> = {
570
603
  plan: {
571
604
  if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
@@ -587,6 +620,7 @@ function prLoopGithubDoc(
587
620
  ...lockJobs,
588
621
  ...commentLockJobs,
589
622
  ...previewJobs,
623
+ ...actionJobs,
590
624
  ...staleJobs,
591
625
  ...Object.fromEntries(applyJobs),
592
626
  ...Object.fromEntries(controlJobs),
@@ -604,7 +638,17 @@ function prLoopGithubDoc(
604
638
  on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
605
639
  }
606
640
  // #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
607
- if (loop.applyBeforeMerge || commentLocks.length > 0 || preview) on.issue_comment = { types: ["created"] };
641
+ if (loop.applyBeforeMerge || commentLocks.length > 0 || preview || action) on.issue_comment = { types: ["created"] };
642
+ // #4654: a dispatch names a member and an action; both empty, it is the dispatch it was before.
643
+ if (action) {
644
+ const input = (description: string) => ({ description, required: false, type: "string", default: "" });
645
+ on.workflow_dispatch = {
646
+ inputs: {
647
+ [action.inputs.member]: input(`The ${resolveProductWords(loop.words).noun[0]} the action runs on`),
648
+ [action.inputs.action]: input("The action to run, by its name"),
649
+ },
650
+ };
651
+ }
608
652
  // #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
609
653
  if (loop.runners) {
610
654
  for (const [name, job] of Object.entries(jobsDoc) as Array<[string, Record<string, unknown>]>) {
@@ -618,7 +662,7 @@ function prLoopGithubDoc(
618
662
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
619
663
  env: { ...options.variables, CHANT_ENV: env },
620
664
  jobsDoc,
621
- stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
665
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(action ? [action.jobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
622
666
  jobs: [
623
667
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
624
668
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
@@ -636,6 +680,7 @@ function prLoopGithubDoc(
636
680
  { jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
637
681
  ]
638
682
  : []),
683
+ ...(action ? [{ jobName: action.jobName, component: "day-2 action", stage: action.jobName, needs: [] }] : []),
639
684
  ...applyNames.map((name, i) => ({
640
685
  jobName: name,
641
686
  component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The action job (#4654): with `actionJob`, the pull request loop's
3
+ * workflow answers `/<product> <action word> <member> <action>` and a
4
+ * dispatch with the member and action inputs, at the target branch's
5
+ * commit, through the product's action verb. A dispatch that names an
6
+ * action starts no apply.
7
+ */
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { parseYAML } from "@intentius/chant/yaml";
11
+ import type { DriverComponent } from "@intentius/chant/components/driver";
12
+ import { generateGithubPipeline } from "./generate-pipeline";
13
+
14
+ const ESTATE: DriverComponent[] = [
15
+ { name: "shop", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "pulumi-up", stack: "shop/prod-us" }] }] },
16
+ ];
17
+
18
+ const WORDS = { product: "plywood", noun: ["stack", "stacks"] as [string, string], commands: { preview: "plan" as const, action: "action" as const }, bin: "npx --no-install plywood" };
19
+
20
+ interface Step { name?: string; uses?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> }
21
+ interface Job { if?: string; needs?: string[]; permissions: Record<string, string>; steps: Step[] }
22
+ type Doc = { on: Record<string, unknown>; jobs: Record<string, Job> };
23
+
24
+ const gen = (prLoop: Record<string, unknown>) => generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml;
25
+
26
+ describe("the GitHub workflow's action job", () => {
27
+ const yaml = gen({ words: WORDS, actionJob: true, applyOnMerge: true });
28
+ const doc = parseYAML(yaml) as Doc;
29
+
30
+ test("a dispatch takes the stack and action inputs, and a comment starts the job too", () => {
31
+ expect(doc.on.workflow_dispatch).toEqual({
32
+ inputs: {
33
+ stack: { description: "The stack the action runs on", required: false, type: "string", default: "" },
34
+ action: { description: "The action to run, by its name", required: false, type: "string", default: "" },
35
+ },
36
+ });
37
+ expect(doc.on.issue_comment).toEqual({ types: ["created"] });
38
+ const job = doc.jobs.action!;
39
+ expect(job.if).toContain("github.event_name == 'workflow_dispatch' && github.event.inputs.action != ''");
40
+ expect(job.if).toContain("startsWith(github.event.comment.body, '/plywood action ')");
41
+ expect(job.if).toContain("github.event.comment.author_association");
42
+ });
43
+
44
+ test("the job runs the product's verb at the target branch's commit, with the comment and inputs as variables", () => {
45
+ const job = doc.jobs.action!;
46
+ expect(job.steps[0]!.uses).toMatch(/^actions\/checkout@/);
47
+ expect(job.steps[0]!.with).toEqual({ "fetch-depth": 0 });
48
+ const run = job.steps.at(-1)!;
49
+ expect(run.run).toContain("npx --no-install plywood action --from-event");
50
+ expect(run.env).toMatchObject({ CHANT_ACTION_COMMENT: "${{ github.event.comment.body }}", CHANT_ACTION_MEMBER: "${{ github.event.inputs.stack }}", CHANT_ACTION: "${{ github.event.inputs.action }}" });
51
+ expect(job.permissions).toEqual({ contents: "write", issues: "write", "pull-requests": "write" });
52
+ });
53
+
54
+ test("a dispatch that names an action starts no apply", () => {
55
+ expect(doc.jobs.apply!.if).toContain("(github.event_name == 'workflow_dispatch' && github.event.inputs.action == '')");
56
+ });
57
+
58
+ test("without actionJob nothing changes", () => {
59
+ const plain = parseYAML(gen({ words: { ...WORDS, commands: { preview: "plan" as const } }, applyOnMerge: true })) as Doc;
60
+ expect(plain.jobs.action).toBeUndefined();
61
+ expect(plain.on.workflow_dispatch).toEqual({});
62
+ });
63
+
64
+ test("golden", async () => {
65
+ await expect(yaml).toMatchFileSnapshot("./__golden__/pr-loop-action.github.yml");
66
+ });
67
+ });