@intentius/chant-lexicon-github 0.124.56 → 0.124.58

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.
@@ -104,8 +104,8 @@ export interface GithubOpPipelineDoc {
104
104
  on: Record<string, unknown>;
105
105
  /** The `env:` mapping, when `options.variables` is set. */
106
106
  env?: Record<string, unknown>;
107
- /** The `concurrency:` mapping — one run at a time per Op. */
108
- concurrency: Record<string, unknown>;
107
+ /** The `concurrency:` mapping — one run at a time per Op. Absent on a waves file (ruling for ci-products 2026-10-28). */
108
+ concurrency?: Record<string, unknown>;
109
109
  /**
110
110
  * The `permissions:` mapping for this Op's finding-mode. GitHub-only:
111
111
  * Forgejo Actions ignores `permissions:` entirely, so the forgejo dialect
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAOhB,gBAAgB,EAIjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAG1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAUlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAIhE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAuOD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,EACrC,KAAK,GAAE,QAAQ,GAAG,SAAoB,GACrC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAuM1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAMnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAOhB,gBAAgB,EAIjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAG1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAUlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAIhE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,yHAAyH;IACzH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAuOD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,EACrC,KAAK,GAAE,QAAQ,GAAG,SAAoB,GACrC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAuM1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAMnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
@@ -513,14 +513,14 @@ function scopeOpFileToMember(file, member) {
513
513
  const { steps, ...rest } = props;
514
514
  return [job, { ...rest, defaults: { run: { "working-directory": member.dir } }, steps }];
515
515
  }));
516
- const group = file.doc.concurrency.group;
516
+ const group = file.doc.concurrency?.group;
517
517
  return {
518
518
  name,
519
519
  doc: {
520
520
  ...file.doc,
521
521
  ...(file.doc.name ? { name: `${member.name}/${file.doc.name}` } : {}),
522
522
  on,
523
- concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) },
523
+ ...(file.doc.concurrency ? { concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) } } : {}),
524
524
  jobsDoc,
525
525
  },
526
526
  };
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Op waves → GitHub Actions workflow (#3679).
3
3
  *
4
- * One workflow for the whole spec, on a push to its branches (and by hand),
5
- * one run at a time. Each job is one of core's `opWaveJobs`: a wave, or a wide
4
+ * One workflow for the whole spec, on a push to its branches (and by hand).
5
+ * Each job is one of core's `opWaveJobs`: a wave, or a wide
6
6
  * wave's deciding job and share jobs, chained by `needs:`. Re-running the
7
7
  * failed jobs after `chant approve` carries on from that wave.
8
8
  *
@@ -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,EAYL,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,CA4FpI;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,CAgGpI;AAoID,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Op waves → GitHub Actions workflow (#3679).
3
3
  *
4
- * One workflow for the whole spec, on a push to its branches (and by hand),
5
- * one run at a time. Each job is one of core's `opWaveJobs`: a wave, or a wide
4
+ * One workflow for the whole spec, on a push to its branches (and by hand).
5
+ * Each job is one of core's `opWaveJobs`: a wave, or a wide
6
6
  * wave's deciding job and share jobs, chained by `needs:`. Re-running the
7
7
  * failed jobs after `chant approve` carries on from that wave.
8
8
  *
@@ -74,7 +74,7 @@
74
74
  * forgejo generator passes `forge: "forgejo"`, which checks the ref and
75
75
  * runner only.
76
76
  */
77
- import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, opWavesApprovalStatusCommand, opWavesPostsApprovalStatus, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
77
+ import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveJobTimeout, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, opWavesApprovalStatusCommand, opWavesPostsApprovalStatus, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
78
78
  import { CONTROL_OUTCOME_VAR, controlDispatchJob } from "@intentius/chant/control/dispatch";
79
79
  import { assertEnvironment, assertSetupSteps, CI_IMAGE, dirnameOf, environmentDoc, GITHUB_JOB_TOKEN, githubRunsOn, githubSecretEnv, idTokenAudienceEnv, nonEmpty, pushBranches, setupStepDoc, } from "@intentius/chant/ci-jobs";
80
80
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
@@ -112,6 +112,8 @@ export function buildGithubOpWavesDoc(spec, options, dialect = {}) {
112
112
  }
113
113
  }
114
114
  const permissions = { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) };
115
+ // The ruling for ci-products (2026-10-28): every wave job carries its timeout and the lock wait under it.
116
+ const timeout = opWaveJobTimeout(options.jobTimeoutMinutes);
115
117
  const jobsDoc = {};
116
118
  for (const job of jobs) {
117
119
  const wave = spec.waves[job.wave - 1];
@@ -159,7 +161,8 @@ export function buildGithubOpWavesDoc(spec, options, dialect = {}) {
159
161
  ...(environment ? { environment: environmentDoc(environment) } : {}),
160
162
  // #3914: a job's own permissions replace the workflow's, so they repeat them.
161
163
  ...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
162
- ...nonEmpty("env", group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables),
164
+ "timeout-minutes": timeout.minutes,
165
+ env: { ...timeout.env, ...(group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables) },
163
166
  steps,
164
167
  };
165
168
  }
@@ -174,7 +177,8 @@ export function buildGithubOpWavesDoc(spec, options, dialect = {}) {
174
177
  name: spec.name,
175
178
  on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
176
179
  ...nonEmpty("env", env),
177
- concurrency: concurrency(spec.name),
180
+ // The ruling for ci-products (2026-10-28): no workflow-level concurrency group. Runs on the target and repo grains wait for
181
+ // their rows on chant's ledger, oldest commit first; plunge stacks on the resource grain lease their resource rows and run.
178
182
  permissions,
179
183
  jobsDoc,
180
184
  };
@@ -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;AAgoBD;;;;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, PR_UNLOCK_COMMAND, 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 = [
@@ -356,6 +359,8 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
356
359
  const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
357
360
  // #4327: with ci.locks: plan the pull_request trigger fires on close too, for the unlock job, and the plan skips that event.
358
361
  const locks = loop.locks === "plan";
362
+ // plywood#22: the lock lines run through the product's bin, as its other job lines do.
363
+ const lockBin = resolveProductWords(loop.words).bin;
359
364
  const closes = Boolean(prEnv) || locks;
360
365
  const lockJobs = locks
361
366
  ? {
@@ -374,7 +379,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
374
379
  {
375
380
  name: "Lock the components the plan selected",
376
381
  env: { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.event.pull_request.head.sha }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN },
377
- run: prLockCommand(forgeName),
382
+ run: prLockCommand(forgeName, lockBin),
378
383
  },
379
384
  ],
380
385
  },
@@ -386,7 +391,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
386
391
  steps: [
387
392
  { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
388
393
  ...runSteps(beforeScript),
389
- { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: PR_UNLOCK_COMMAND },
394
+ { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: prUnlockCommand(lockBin) },
390
395
  ],
391
396
  },
392
397
  }
@@ -465,6 +470,34 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
465
470
  },
466
471
  }
467
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
+ : {};
468
501
  const jobsDoc = {
469
502
  plan: {
470
503
  if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
@@ -486,6 +519,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
486
519
  ...lockJobs,
487
520
  ...commentLockJobs,
488
521
  ...previewJobs,
522
+ ...actionJobs,
489
523
  ...staleJobs,
490
524
  ...Object.fromEntries(applyJobs),
491
525
  ...Object.fromEntries(controlJobs),
@@ -504,8 +538,18 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
504
538
  on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
505
539
  }
506
540
  // #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
507
- if (loop.applyBeforeMerge || commentLocks.length > 0 || preview)
541
+ if (loop.applyBeforeMerge || commentLocks.length > 0 || preview || action)
508
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
+ }
509
553
  // #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
510
554
  if (loop.runners) {
511
555
  for (const [name, job] of Object.entries(jobsDoc)) {
@@ -519,7 +563,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
519
563
  ...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
520
564
  env: { ...options.variables, CHANT_ENV: env },
521
565
  jobsDoc,
522
- 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"] : [])],
523
567
  jobs: [
524
568
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
525
569
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
@@ -537,6 +581,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
537
581
  { jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
538
582
  ]
539
583
  : []),
584
+ ...(action ? [{ jobName: action.jobName, component: "day-2 action", stage: action.jobName, needs: [] }] : []),
540
585
  ...applyNames.map((name, i) => ({
541
586
  jobName: name,
542
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": "16478bfa3a3a6b6a573c54713b9d6edcef575a934df2b6a2daba331607d0d132",
4
+ "manifest.json": "418be40e957e0634548ba848e3b607febd9850185be3dfd8d278b7cb356aa61a",
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": "9eb8c4e72039a3e7c014959fe55b6846823a49f4097e41c3d4b2b9baebe12ad4"
81
+ "composite": "f8d68a8078ebf75ac45adc4efce5ab4b263bc8c4808cdee6c07380458a1f0390"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.56",
3
+ "version": "0.124.58",
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.56",
3
+ "version": "0.124.58",
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.56"
64
+ "@intentius/chant": "^0.124.58"
65
65
  }
66
66
  }
@@ -6,10 +6,6 @@ on:
6
6
  - main
7
7
  workflow_dispatch: {}
8
8
 
9
- concurrency:
10
- group: migrations
11
- cancel-in-progress: false
12
-
13
9
  permissions:
14
10
  contents: write
15
11
 
@@ -19,6 +15,9 @@ jobs:
19
15
  - self-hosted
20
16
  - dev
21
17
  container: node:22
18
+ timeout-minutes: 180
19
+ env:
20
+ CHANT_LOCK_WAIT: '10500'
22
21
  steps:
23
22
  - name: Check the job runs where the spec set it to run
24
23
  env:
@@ -47,7 +46,9 @@ jobs:
47
46
  permissions:
48
47
  contents: write
49
48
  id-token: write
49
+ timeout-minutes: 180
50
50
  env:
51
+ CHANT_LOCK_WAIT: '10500'
51
52
  STAGE: apply
52
53
  PLAN_TOKEN_AUDIENCE: plan.example
53
54
  steps:
@@ -95,7 +96,9 @@ jobs:
95
96
  permissions:
96
97
  contents: write
97
98
  id-token: write
99
+ timeout-minutes: 180
98
100
  env:
101
+ CHANT_LOCK_WAIT: '10500'
99
102
  STAGE: apply
100
103
  REGION: us
101
104
  CLOUD_TOKEN_AUDIENCE: us.example
@@ -141,7 +144,9 @@ jobs:
141
144
  permissions:
142
145
  contents: write
143
146
  id-token: write
147
+ timeout-minutes: 180
144
148
  env:
149
+ CHANT_LOCK_WAIT: '10500'
145
150
  STAGE: apply
146
151
  REGION: us
147
152
  CLOUD_TOKEN_AUDIENCE: us.example
@@ -184,7 +189,9 @@ jobs:
184
189
  permissions:
185
190
  contents: write
186
191
  id-token: write
192
+ timeout-minutes: 180
187
193
  env:
194
+ CHANT_LOCK_WAIT: '10500'
188
195
  STAGE: apply
189
196
  PLAN_TOKEN_AUDIENCE: plan.example
190
197
  steps:
@@ -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
@@ -141,8 +141,8 @@ export interface GithubOpPipelineDoc {
141
141
  on: Record<string, unknown>;
142
142
  /** The `env:` mapping, when `options.variables` is set. */
143
143
  env?: Record<string, unknown>;
144
- /** The `concurrency:` mapping — one run at a time per Op. */
145
- concurrency: Record<string, unknown>;
144
+ /** The `concurrency:` mapping — one run at a time per Op. Absent on a waves file (ruling for ci-products 2026-10-28). */
145
+ concurrency?: Record<string, unknown>;
146
146
  /**
147
147
  * The `permissions:` mapping for this Op's finding-mode. GitHub-only:
148
148
  * Forgejo Actions ignores `permissions:` entirely, so the forgejo dialect
@@ -655,14 +655,14 @@ function scopeOpFileToMember(file: GithubOpPipelineFile, member: PipelineMember)
655
655
  return [job, { ...rest, defaults: { run: { "working-directory": member.dir } }, steps }];
656
656
  }),
657
657
  );
658
- const group = (file.doc.concurrency as { group?: unknown }).group;
658
+ const group = (file.doc.concurrency as { group?: unknown } | undefined)?.group;
659
659
  return {
660
660
  name,
661
661
  doc: {
662
662
  ...file.doc,
663
663
  ...(file.doc.name ? { name: `${member.name}/${file.doc.name}` } : {}),
664
664
  on,
665
- concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) },
665
+ ...(file.doc.concurrency ? { concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) } } : {}),
666
666
  jobsDoc,
667
667
  },
668
668
  };
@@ -105,14 +105,14 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
105
105
  const yaml = files[0]!.yaml;
106
106
  expect(yaml).toMatch(/wave-1-dev:\n\s+runs-on:\n\s+- self-hosted\n\s+- dev\n/);
107
107
  expect(yaml).toMatch(
108
- /wave-2-prod-decide:\n\s+runs-on: plan\n[\s\S]*?permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/,
108
+ /wave-2-prod-decide:\n\s+runs-on: plan\n[\s\S]*?permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/,
109
109
  );
110
110
  expect(yaml).toMatch(
111
- /wave-2-prod-us-share-1:\n\s+runs-on:\n\s+group: prod-apply\n\s+labels:\n\s+- linux\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+environment:\n\s+name: prod-us\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/,
111
+ /wave-2-prod-us-share-1:\n\s+runs-on:\n\s+group: prod-apply\n\s+labels:\n\s+- linux\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+environment:\n\s+name: prod-us\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/,
112
112
  );
113
113
  expect(yaml).toContain("- run: echo us\n - name: Apply share 1 of job group us of wave 2 (prod)\n run: chant run wave --spec waves.json --wave 2 --group us --share 1\n");
114
114
  // A group with no id tokens of its own gets the wave's.
115
- expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n[\s\S]*?name: prod-eu\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/);
115
+ expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n[\s\S]*?name: prod-eu\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/);
116
116
  expect(yaml).toContain("run: chant run wave --spec waves.json --wave 2 --group eu\n");
117
117
  });
118
118
 
@@ -139,3 +139,19 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
139
139
  expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
140
140
  });
141
141
  });
142
+
143
+ describe("the ledger lock (ruling for ci-products 2026-10-28)", () => {
144
+ test("the waves workflow carries no concurrency group; each wave job carries its timeout and the lock wait under it", () => {
145
+ const yaml = generateGithubOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
146
+ expect(yaml).not.toMatch(/^concurrency:/m);
147
+ expect(yaml).not.toContain("group: migrations");
148
+ expect(yaml.match(/timeout-minutes: 180\n/g)).toHaveLength(4);
149
+ expect(yaml.match(/CHANT_LOCK_WAIT: '10500'\n/g)).toHaveLength(4);
150
+ });
151
+
152
+ test("jobTimeoutMinutes sets both", () => {
153
+ const yaml = generateGithubOpWavesPipeline(spec, { specFile: "waves.json", jobTimeoutMinutes: 60 }).files[0]!.yaml;
154
+ expect(yaml).toContain("timeout-minutes: 60\n");
155
+ expect(yaml).toContain("CHANT_LOCK_WAIT: '3300'\n");
156
+ });
157
+ });
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Op waves → GitHub Actions workflow (#3679).
3
3
  *
4
- * One workflow for the whole spec, on a push to its branches (and by hand),
5
- * one run at a time. Each job is one of core's `opWaveJobs`: a wave, or a wide
4
+ * One workflow for the whole spec, on a push to its branches (and by hand).
5
+ * Each job is one of core's `opWaveJobs`: a wave, or a wide
6
6
  * wave's deciding job and share jobs, chained by `needs:`. Re-running the
7
7
  * failed jobs after `chant approve` carries on from that wave.
8
8
  *
@@ -79,6 +79,7 @@ import {
79
79
  notifyVariables,
80
80
  opWaveGroupOf,
81
81
  opWaveJobs,
82
+ opWaveJobTimeout,
82
83
  opWaveOutcomeDir,
83
84
  opWavesControlTarget,
84
85
  opWavesLastJobs,
@@ -146,6 +147,8 @@ export function buildGithubOpWavesDoc(
146
147
  }
147
148
  }
148
149
  const permissions = { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) };
150
+ // The ruling for ci-products (2026-10-28): every wave job carries its timeout and the lock wait under it.
151
+ const timeout = opWaveJobTimeout(options.jobTimeoutMinutes);
149
152
 
150
153
  const jobsDoc: Record<string, unknown> = {};
151
154
  for (const job of jobs) {
@@ -194,7 +197,8 @@ export function buildGithubOpWavesDoc(
194
197
  ...(environment ? { environment: environmentDoc(environment) } : {}),
195
198
  // #3914: a job's own permissions replace the workflow's, so they repeat them.
196
199
  ...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
197
- ...nonEmpty("env", group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables),
200
+ "timeout-minutes": timeout.minutes,
201
+ env: { ...timeout.env, ...(group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables) },
198
202
  steps,
199
203
  };
200
204
  }
@@ -211,7 +215,8 @@ export function buildGithubOpWavesDoc(
211
215
  name: spec.name,
212
216
  on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
213
217
  ...nonEmpty("env", env),
214
- concurrency: concurrency(spec.name),
218
+ // The ruling for ci-products (2026-10-28): no workflow-level concurrency group. Runs on the target and repo grains wait for
219
+ // their rows on chant's ledger, oldest commit first; plunge stacks on the resource grain lease their resource rows and run.
215
220
  permissions,
216
221
  jobsDoc,
217
222
  };
@@ -78,10 +78,12 @@ import {
78
78
  PR_PLAN_NOTE_JOB,
79
79
  PR_LOCK_JOB,
80
80
  PR_UNLOCK_JOB,
81
- PR_UNLOCK_COMMAND,
81
+ prUnlockCommand,
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 = [
@@ -453,6 +458,8 @@ function prLoopGithubDoc(
453
458
  const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
454
459
  // #4327: with ci.locks: plan the pull_request trigger fires on close too, for the unlock job, and the plan skips that event.
455
460
  const locks = loop.locks === "plan";
461
+ // plywood#22: the lock lines run through the product's bin, as its other job lines do.
462
+ const lockBin = resolveProductWords(loop.words).bin;
456
463
  const closes = Boolean(prEnv) || locks;
457
464
  const lockJobs: Record<string, unknown> = locks
458
465
  ? {
@@ -471,7 +478,7 @@ function prLoopGithubDoc(
471
478
  {
472
479
  name: "Lock the components the plan selected",
473
480
  env: { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.event.pull_request.head.sha }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN },
474
- run: prLockCommand(forgeName),
481
+ run: prLockCommand(forgeName, lockBin),
475
482
  },
476
483
  ],
477
484
  },
@@ -483,7 +490,7 @@ function prLoopGithubDoc(
483
490
  steps: [
484
491
  { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
485
492
  ...runSteps(beforeScript),
486
- { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: PR_UNLOCK_COMMAND },
493
+ { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: prUnlockCommand(lockBin) },
487
494
  ],
488
495
  },
489
496
  }
@@ -564,6 +571,34 @@ function prLoopGithubDoc(
564
571
  },
565
572
  }
566
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
+ : {};
567
602
  const jobsDoc: Record<string, unknown> = {
568
603
  plan: {
569
604
  if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
@@ -585,6 +620,7 @@ function prLoopGithubDoc(
585
620
  ...lockJobs,
586
621
  ...commentLockJobs,
587
622
  ...previewJobs,
623
+ ...actionJobs,
588
624
  ...staleJobs,
589
625
  ...Object.fromEntries(applyJobs),
590
626
  ...Object.fromEntries(controlJobs),
@@ -602,7 +638,17 @@ function prLoopGithubDoc(
602
638
  on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
603
639
  }
604
640
  // #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
605
- 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
+ }
606
652
  // #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
607
653
  if (loop.runners) {
608
654
  for (const [name, job] of Object.entries(jobsDoc) as Array<[string, Record<string, unknown>]>) {
@@ -616,7 +662,7 @@ function prLoopGithubDoc(
616
662
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
617
663
  env: { ...options.variables, CHANT_ENV: env },
618
664
  jobsDoc,
619
- 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"] : [])],
620
666
  jobs: [
621
667
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
622
668
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
@@ -634,6 +680,7 @@ function prLoopGithubDoc(
634
680
  { jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
635
681
  ]
636
682
  : []),
683
+ ...(action ? [{ jobName: action.jobName, component: "day-2 action", stage: action.jobName, needs: [] }] : []),
637
684
  ...applyNames.map((name, i) => ({
638
685
  jobName: name,
639
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
+ });
@@ -56,6 +56,16 @@ describe("the GitHub workflow with ci.locks: plan", () => {
56
56
  });
57
57
  });
58
58
 
59
+ describe("a product's words (plywood#22)", () => {
60
+ test("the lock and unlock lines run through the product's bin, so its job image needs no chant on PATH", () => {
61
+ const doc = gen({ locks: "plan", words: { product: "plywood", bin: "npx --no-install plywood" } });
62
+ const lock = doc.jobs.lock!.steps.find((s) => s.run?.includes(" lock --from-report"))!;
63
+ expect(lock.run).toBe('npx --no-install plywood lock --from-report .chant/pr/pr-plan.json --pr "$PR_NUMBER" --head "$HEAD_SHA" --stage plan --forge github');
64
+ const unlock = doc.jobs.unlock!.steps.find((s) => s.run?.includes(" unlock --pr"))!;
65
+ expect(unlock.run).toBe('npx --no-install plywood unlock --pr "$PR_NUMBER"');
66
+ });
67
+ });
68
+
59
69
  describe("with a pull request environment too", () => {
60
70
  test("the pull_request types and the plan condition are the same as with the environment alone", () => {
61
71
  const both = gen({ locks: "plan", prEnvironment: true });