@intentius/chant-lexicon-github 0.124.54 → 0.124.55

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.
Files changed (30) hide show
  1. package/dist/components/agent-jobs.d.ts +67 -0
  2. package/dist/components/agent-jobs.d.ts.map +1 -0
  3. package/dist/components/agent-jobs.js +139 -0
  4. package/dist/components/control-pipeline.d.ts +34 -0
  5. package/dist/components/control-pipeline.d.ts.map +1 -0
  6. package/dist/components/control-pipeline.js +45 -0
  7. package/dist/components/generate-op-pipeline.d.ts +8 -1
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +75 -12
  10. package/dist/components/generate-op-waves-pipeline.d.ts +13 -0
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +62 -4
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +133 -14
  15. package/dist/integrity.json +2 -2
  16. package/dist/manifest.json +1 -1
  17. package/package.json +2 -2
  18. package/src/components/agent-jobs.ts +177 -0
  19. package/src/components/control-pipeline.ts +65 -0
  20. package/src/components/generate-op-pipeline.agent.test.ts +147 -0
  21. package/src/components/generate-op-pipeline.notice.test.ts +2 -1
  22. package/src/components/generate-op-pipeline.ts +82 -11
  23. package/src/components/generate-op-waves-pipeline.control.test.ts +64 -0
  24. package/src/components/generate-op-waves-pipeline.test.ts +17 -0
  25. package/src/components/generate-op-waves-pipeline.ts +81 -4
  26. package/src/components/generate-pipeline.ts +139 -14
  27. package/src/components/generate-pr-pipeline.comment-locks.test.ts +53 -0
  28. package/src/components/generate-pr-pipeline.comment-preview.test.ts +64 -0
  29. package/src/components/generate-pr-pipeline.control.test.ts +94 -0
  30. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +43 -0
@@ -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;AAgCjG,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;AAgfD;;;;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;AAuCjG,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;AAsmBD;;;;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,11 +61,16 @@ import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/ga
61
61
  import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER } 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 { APPLY_COMMENT, 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, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, 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, PR_UNLOCK_COMMAND, prLockCommand, prCommentLockJobs, prCommentPreviewJob, PREVIEW_REF_ENV, PREVIEW_HEAD_OUTPUT, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, PR_ENV_REPORT_DIR, PR_ENV_SWEEP_CRON, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
65
+ import { forgejoRepoLockLines, REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
65
66
  import { memberRepoPath } from "@intentius/chant/lexicon";
67
+ import { productStatusContext, resolveProductWords } from "@intentius/chant/ci-product-words";
68
+ import { controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
66
69
  import { afterEarlierWave, CACHE_RESTORE, CACHE_SAVE, CHECKOUT, checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs.js";
67
70
  /** The plan job's last step, whose outputs carry its report to the plan-note job (#4295). */
68
71
  const PLAN_STEP_ID = "plan";
72
+ /** The comment preview's checkout step (#4620), whose output names the head it checked out. */
73
+ const PREVIEW_HEAD_STEP_ID = "head";
69
74
  /** The output a gated apply wave hands the green commit it applies through, and the step that sets it (#4221). */
70
75
  const WAVE_GREEN_OUTPUT = "green-sha";
71
76
  const WAVE_STEP_ID = "apply";
@@ -203,10 +208,12 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
203
208
  });
204
209
  const withIdToken = (permissions) => (identity ? { ...permissions, "id-token": "write" } : permissions);
205
210
  const reportName = (job) => `chant-pr-${job.wave ? prApplyWaveJobName(job.wave) : job.jobName}`;
211
+ // #4552: on the repo grain a Forgejo apply takes the lock tag in its own step, before the command, since the runner does not queue on a concurrency group.
212
+ const repoLock = (job) => (loop.lockGrain === "repo" && loop.forge === "forgejo" && job.jobName !== "record" ? forgejoRepoLockLines() : []);
206
213
  const steps = (job, name) => [
207
214
  ...(identity ? [tradeStep(job.jobName === "record" ? identity.plan : identity.apply, job.jobName === "record" ? "plan" : "apply")] : []),
208
215
  checkoutStep(true),
209
- ...runSteps(prLoopSetup(image)),
216
+ ...runSteps(prLoopSetup(image, loop.engineSetup)),
210
217
  ...runSteps(beforeScript),
211
218
  ...(job.build ? [{ name: "Build the templates", run: job.build[0] }, { name: "Lint the project", run: job.build[1] }] : []),
212
219
  ...(job.jobName === "apply" ? [restoreRecord(job.wave)] : []),
@@ -218,9 +225,9 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
218
225
  name,
219
226
  id: WAVE_STEP_ID,
220
227
  env: { ...job.run.env, ...(job.wave > 1 ? { GREEN_SHA: `\${{ needs.${prApplyWaveJobName(job.wave - 1)}.outputs.${WAVE_GREEN_OUTPUT} }}` } : {}) },
221
- run: [...(job.checkout ?? []), `echo "${WAVE_GREEN_OUTPUT}=$GREEN_SHA" >> "$GITHUB_OUTPUT"`, ...(job.setup ?? []), job.run.command].join("\n"),
228
+ run: [...(job.checkout ?? []), `echo "${WAVE_GREEN_OUTPUT}=$GREEN_SHA" >> "$GITHUB_OUTPUT"`, ...(job.setup ?? []), ...repoLock(job), job.run.command].join("\n"),
222
229
  }
223
- : { name, env: job.run.env, run: [...(job.checkout ?? []), ...(job.setup ?? []), job.run.command].join("\n") },
230
+ : { name, env: job.run.env, run: [...(job.checkout ?? []), ...(job.setup ?? []), ...repoLock(job), job.run.command].join("\n") },
224
231
  ...runSteps(extraScript),
225
232
  ...(job.jobName === "apply" ? [saveRecord(job.wave)] : []),
226
233
  {
@@ -239,7 +246,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
239
246
  "github.event_name == 'issue_comment'",
240
247
  // Forgejo's payload says so with is_pull (terragucci comment-apply.ts:129 at e6cb431).
241
248
  forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
242
- `startsWith(github.event.comment.body, '${APPLY_COMMENT}')`,
249
+ `startsWith(github.event.comment.body, '${prApplyComment(loop)}')`,
243
250
  ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
244
251
  ].join(" && ");
245
252
  const applyJob = (job, needs, name) => ({
@@ -248,7 +255,8 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
248
255
  "runs-on": GITHUB_RUNNER,
249
256
  ...(needs.length > 0 ? { needs } : {}),
250
257
  container: image,
251
- concurrency: concurrency(prApplyGroup(env, member?.name)),
258
+ // #4552: on the repo grain every apply of the repository shares one group.
259
+ concurrency: concurrency(loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name)),
252
260
  permissions: withIdToken({ contents: "write", "pull-requests": "write", statuses: "write" }),
253
261
  ...(job.wave ? { outputs: { [WAVE_GREEN_OUTPUT]: `\${{ steps.${WAVE_STEP_ID}.outputs.${WAVE_GREEN_OUTPUT} }}` } } : {}),
254
262
  steps: steps(job, name),
@@ -281,7 +289,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
281
289
  env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
282
290
  run: checkoutScript(true, true),
283
291
  },
284
- ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
292
+ ...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
285
293
  ...(plan.build ? [headStep(plan.build[0], "Build the templates"), headStep(plan.build[1], "Lint the project")] : []),
286
294
  // The job's last step: a step after it would start beside whatever the change's code left running.
287
295
  {
@@ -291,14 +299,40 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
291
299
  run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), ...(plan.checkout ?? []), ...(plan.setup ?? []), plan.run.command, ...extraScript].join("\n"),
292
300
  },
293
301
  ];
294
- const post = prLoopPostJob(forgeName, env, member?.name);
302
+ // #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
303
+ const lastGreen = loop.control && !loop.applyBeforeMerge && greenJobs.length > 0 ? greenJobs[greenJobs.length - 1][0] : undefined;
304
+ const controlJobs = [];
305
+ if (loop.control && lastGreen) {
306
+ // A wave job hands on the commit it applied; a single apply job applies the green commit its trigger names.
307
+ const sha = loop.waveGate ? `\${{ needs.${lastGreen}.outputs.${WAVE_GREEN_OUTPUT} }}` : forgejo ? "${{ github.sha }}" : "${{ github.event.workflow_run.head_sha || github.sha }}";
308
+ const dispatch = controlDispatchJob(forgeName, loop.control.target, sha, {
309
+ ...(loop.words ? { words: loop.words } : {}),
310
+ ...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
311
+ ...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
312
+ });
313
+ controlJobs.push([
314
+ dispatch.name,
315
+ {
316
+ needs: [lastGreen],
317
+ if: `success() && (${onGreen})`,
318
+ "runs-on": GITHUB_RUNNER,
319
+ container: image,
320
+ permissions: loop.control.markSent ? { contents: "read", statuses: "write" } : { contents: "read" },
321
+ steps: [{ name: dispatch.stepName, ...(rooted ? {} : { "working-directory": "." }), env: { ...dispatch.env, ...(loop.control.sentCheck || loop.control.markSent ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {}) }, run: dispatch.script }],
322
+ },
323
+ ]);
324
+ }
325
+ const post = prLoopPostJob(forgeName, env, member?.name, loop.words ? resolveProductWords(loop.words) : undefined);
326
+ // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
327
+ const controlRead = loop.control ? controlNoteCheckout(forgeName, loop.control.target, loop.words ?? {}) : undefined;
295
328
  const noteSteps = [
296
329
  // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
297
330
  { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}" } },
298
331
  ...runSteps(beforeScript),
299
332
  { name: "Take the plan report from the plan job", run: takeOverScript("plan", PR_LOOP_REPORT_DIR, forgeName) },
300
333
  { name: "Keep the plan report", if: "always()", uses: UPLOAD_ARTIFACT, with: { name: reportName(plan), path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } },
301
- { name: "Post the plan note and chant/plan", if: "always()", env: post.env, run: post.command },
334
+ ...(controlRead ? [{ name: controlRead.stepName, if: "always()", env: controlRead.env, run: controlRead.script }] : []),
335
+ { name: `Post the plan note and ${productStatusContext(resolveProductWords(loop.words), "plan")}`, if: "always()", env: post.env, run: post.command },
302
336
  ];
303
337
  // #4298: with a pull request environment the pull_request trigger fires on close too, and the plan skips that event.
304
338
  const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
@@ -339,6 +373,80 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
339
373
  },
340
374
  }
341
375
  : {};
376
+ // #4588: `/<product> lock` and `/<product> unlock` comments, at the target branch's commit, so none of the change's code runs.
377
+ const commentLocks = prCommentLockJobs(forgeName, loop);
378
+ const commentLockJobs = Object.fromEntries(commentLocks.map((job) => [
379
+ job.jobName,
380
+ {
381
+ if: [
382
+ "github.event_name == 'issue_comment'",
383
+ forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
384
+ `startsWith(github.event.comment.body, '${job.comment}')`,
385
+ ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
386
+ ].join(" && "),
387
+ "runs-on": GITHUB_RUNNER,
388
+ container: image,
389
+ permissions: { contents: "write", "pull-requests": "read", statuses: "write" },
390
+ steps: [
391
+ { name: "Check out the target branch's commit", uses: CHECKOUT, with: { "fetch-depth": 0 } },
392
+ ...runSteps(beforeScript),
393
+ { name: job.command === "lock" ? "Keep the locks the pull request's plan took" : "Release the pull request's locks", env: job.env, run: job.line },
394
+ ],
395
+ },
396
+ ]));
397
+ // #4620: `/<product> <plan word>` plans the pull request's head again with a read token; the job after it posts the note.
398
+ const preview = prCommentPreviewJob(forgeName, env, loop, member?.name);
399
+ const previewJobs = preview
400
+ ? {
401
+ [preview.jobName]: {
402
+ if: [
403
+ "github.event_name == 'issue_comment'",
404
+ forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
405
+ `startsWith(github.event.comment.body, '${preview.comment}')`,
406
+ ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
407
+ ].join(" && "),
408
+ "runs-on": GITHUB_RUNNER,
409
+ container: image,
410
+ permissions: withIdToken({ contents: "read" }),
411
+ outputs: { ...handoverOutputs(PLAN_STEP_ID, forgeName), [PREVIEW_HEAD_OUTPUT]: `\${{ steps.${PREVIEW_HEAD_STEP_ID}.outputs.${PREVIEW_HEAD_OUTPUT} }}` },
412
+ steps: [
413
+ ...(identity ? [tradeStep(identity.plan, "plan")] : []),
414
+ // The head is read here, before any of the change's code runs, so the post job names the commit this job planned.
415
+ {
416
+ name: "Check out the pull request's head",
417
+ id: PREVIEW_HEAD_STEP_ID,
418
+ ...(rooted ? {} : { "working-directory": "." }),
419
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN, [PREVIEW_REF_ENV]: "ref" in preview.head ? preview.head.ref : "" },
420
+ run: [checkoutScript(true, true, { refEnv: PREVIEW_REF_ENV }), `echo "${PREVIEW_HEAD_OUTPUT}=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"`].join("\n"),
421
+ },
422
+ ...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
423
+ ...(preview.plan.build ? [headStep(preview.plan.build[0], "Build the templates"), headStep(preview.plan.build[1], "Lint the project")] : []),
424
+ {
425
+ name: "Preview the members this pull request reaches",
426
+ id: PLAN_STEP_ID,
427
+ env: preview.plan.run.env,
428
+ run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), preview.base, ...(preview.plan.setup ?? []), preview.plan.run.command, ...extraScript].join("\n"),
429
+ },
430
+ ],
431
+ },
432
+ [preview.noteJobName]: {
433
+ needs: [preview.jobName],
434
+ if: `always() && github.event_name == 'issue_comment' && (needs.${preview.jobName}.result == 'success' || needs.${preview.jobName}.result == 'failure')`,
435
+ "runs-on": GITHUB_RUNNER,
436
+ container: image,
437
+ permissions: { actions: "read", contents: "read", "pull-requests": "write", statuses: "write" },
438
+ steps: [
439
+ // The target branch's commit, which an issue_comment run checks out: none of the change's code is here.
440
+ { name: "Check out the target branch's commit", uses: CHECKOUT },
441
+ ...runSteps(beforeScript),
442
+ { name: "Take the preview report from the preview job", run: takeOverScript(preview.jobName, PR_LOOP_REPORT_DIR, forgeName) },
443
+ { name: "Keep the preview report", if: "always()", uses: UPLOAD_ARTIFACT, with: { name: `chant-pr-${preview.jobName}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } },
444
+ ...(controlRead ? [{ name: controlRead.stepName, if: "always()", env: controlRead.env, run: controlRead.script }] : []),
445
+ { name: `Post the preview note and ${productStatusContext(resolveProductWords(loop.words), "plan")}`, if: "always()", env: preview.post.env, run: preview.post.command },
446
+ ],
447
+ },
448
+ }
449
+ : {};
342
450
  const jobsDoc = {
343
451
  plan: {
344
452
  if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
@@ -358,7 +466,10 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
358
466
  steps: noteSteps,
359
467
  },
360
468
  ...lockJobs,
469
+ ...commentLockJobs,
470
+ ...previewJobs,
361
471
  ...Object.fromEntries(applyJobs),
472
+ ...Object.fromEntries(controlJobs),
362
473
  ...(prEnv ? prEnv.jobs : {}),
363
474
  };
364
475
  const applyNames = applyJobs.map(([name]) => name);
@@ -373,8 +484,8 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
373
484
  on.pull_request = { branches: [branch], types: ["opened", "synchronize", "reopened", "closed"] };
374
485
  on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
375
486
  }
376
- // #4301: a /chant apply comment starts the apply before merge.
377
- if (loop.applyBeforeMerge)
487
+ // #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
488
+ if (loop.applyBeforeMerge || commentLocks.length > 0 || preview)
378
489
  on.issue_comment = { types: ["created"] };
379
490
  return {
380
491
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
@@ -383,7 +494,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
383
494
  ...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
384
495
  env: { ...options.variables, CHANT_ENV: env },
385
496
  jobsDoc,
386
- stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...applyNames, ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
497
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
387
498
  jobs: [
388
499
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
389
500
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
@@ -393,6 +504,13 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
393
504
  { jobName: PR_UNLOCK_JOB, component: "pull request unlock", stage: PR_UNLOCK_JOB, needs: [] },
394
505
  ]
395
506
  : []),
507
+ ...commentLocks.map((j) => ({ jobName: j.jobName, component: `pull request ${j.command} comment`, stage: j.jobName, needs: [] })),
508
+ ...(preview
509
+ ? [
510
+ { jobName: preview.jobName, component: "pull request preview comment", stage: preview.jobName, needs: [] },
511
+ { jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
512
+ ]
513
+ : []),
396
514
  ...applyNames.map((name, i) => ({
397
515
  jobName: name,
398
516
  component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
@@ -400,6 +518,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
400
518
  // The record runs on a push and the apply on a green commit, so only the waves need each other.
401
519
  needs: i > 1 ? [applyNames[i - 1]] : [],
402
520
  })),
521
+ ...controlJobs.map(([name]) => ({ jobName: name, component: "control repo dispatch", stage: name, needs: [lastGreen] })),
403
522
  ...(prEnv ? prEnv.listed : []),
404
523
  ],
405
524
  };
@@ -451,7 +570,7 @@ function prEnvGithubJobs(forge, image, beforeScript, extraScript, loop, member)
451
570
  env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
452
571
  run: checkoutScript(true, true),
453
572
  },
454
- ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
573
+ ...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
455
574
  ...(up.build ? [headStep(up.build[0], "Build the templates", { CHANT_ENV: up.env.CHANT_ENV }), headStep(up.build[1], "Lint the project")] : []),
456
575
  // The job's last step: a step after it would start beside whatever the change's code left running.
457
576
  {
@@ -487,7 +606,7 @@ function prEnvGithubJobs(forge, image, beforeScript, extraScript, loop, member)
487
606
  steps: [
488
607
  ...tradeStep,
489
608
  { uses: CHECKOUT, with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
490
- ...runSteps(prLoopSetup(image)),
609
+ ...runSteps(prLoopSetup(image, loop.engineSetup)),
491
610
  ...runSteps(beforeScript),
492
611
  { name, env: j.env, run: j.command },
493
612
  ...runSteps(extraScript),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "4e53c568b6c55a08741d8b4f83c7965d1b0d710e540b4efb66ac88735de02380",
4
+ "manifest.json": "3513bb8a86e9d8cf394877a70b42741598681301a7c892fa94a65856cd1742ac",
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": "63d4e69414d1e6bfbf5968e7fcdcc1930ecca6d07b3915bdd013f9d39f77d2d6"
81
+ "composite": "d2ef289d1dabdd39c05090b1dc9e881fa69fd0934c0cb3a197d8ca9abb4f5a45"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.54",
3
+ "version": "0.124.55",
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.54",
3
+ "version": "0.124.55",
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.54"
64
+ "@intentius/chant": "^0.124.55"
65
65
  }
66
66
  }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The agent jobs on GitHub and Forgejo (#4616): `ScheduledOpSpec.agent`.
3
+ * What each job does is @intentius/chant/ci-agent's; this file lays them
4
+ * out so the model never shares a job with a write token:
5
+ *
6
+ * - the agent job holds `contents: read` and `pull-requests: read`. Its
7
+ * checkout script alone sees the job token, for the fetches (the target
8
+ * branch's commit, chant/lifecycle for the run count, and for a comment
9
+ * the pull request's head). `chant agent prepare` reads with the job
10
+ * token; `chant agent run` is the last step and starts without any forge
11
+ * token, with the model's key from its secret, and hands the agent
12
+ * directory on as the job's outputs (`handoverTrap`).
13
+ * - the push job checks out the target branch's commit, takes the agent
14
+ * directory back as data and runs `chant agent land` with the push token:
15
+ * `tokenEnv`'s secret, else the job token. A push made with the job token
16
+ * starts no workflow on GitHub, so the pull request is planned again only
17
+ * with a token of a user or an app.
18
+ *
19
+ * Forgejo's runner ignores `permissions:` and has no author association, so
20
+ * there `chant agent prepare` asks the forge whether the commenter can write,
21
+ * and both jobs drop the token variables before any step that does not need
22
+ * them.
23
+ */
24
+
25
+ import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
26
+ import { githubRunsOn, GITHUB_JOB_TOKEN, githubSecret, githubSecretOrJobToken, type CiRunner } from "@intentius/chant/ci-jobs";
27
+ import {
28
+ AGENT_DECISION_FILE,
29
+ AGENT_DIR,
30
+ AGENT_DRIFT_FILE,
31
+ AGENT_PATCH_FILE,
32
+ AGENT_RC_FILE,
33
+ agentCommandLines,
34
+ agentGitAuthLines,
35
+ agentHeadFetchLine,
36
+ agentSettingsEnv,
37
+ type AgentSettings,
38
+ } from "@intentius/chant/ci-agent";
39
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
40
+
41
+ /** The agent step's id, which the agent job's outputs come from. */
42
+ export const AGENT_STEP_ID = "chant-agent";
43
+ /** The drift Op's run step id, whose outputs carry the drift and the code. */
44
+ export const AGENT_DRIFT_STEP_ID = "chant-run";
45
+ /** The variable the push step holds its token in, for git's header and the forge client. */
46
+ const PUSH_TOKEN_ENV = "CHANT_AGENT_TOKEN";
47
+
48
+ /** The files the handover keeps when the agent directory is too large to hand on whole. */
49
+ const HANDED = { label: "chant-agent", noteFiles: [AGENT_DECISION_FILE, AGENT_RC_FILE, AGENT_PATCH_FILE, "prompt.sha256"] } as const;
50
+ const DRIFT_HANDED = { label: "chant-agent", noteFiles: [AGENT_DRIFT_FILE] } as const;
51
+
52
+ /** The name of the push job beside the agent job. */
53
+ export function agentPushJobName(jobName: string): string {
54
+ return `${jobName}-push`;
55
+ }
56
+
57
+ /** The agent job's name for a drift Op's job. */
58
+ export function agentJobName(jobName: string, on: AgentSettings["on"]): string {
59
+ return on === "drift" ? `${jobName}-agent` : jobName;
60
+ }
61
+
62
+ export interface GithubAgentJobsInput {
63
+ forge: "github" | "forgejo";
64
+ /** The agent job's name. */
65
+ jobName: string;
66
+ image: string;
67
+ settings: AgentSettings;
68
+ runner?: CiRunner;
69
+ /** Setup steps, as built by `setupStepDoc`. */
70
+ setup: Array<Record<string, unknown>>;
71
+ beforeScript: readonly string[];
72
+ /** The secret holding the push token; absent: the job token. */
73
+ tokenEnv?: string;
74
+ /** The command in place of `chant`: the product's bin. */
75
+ bin?: string;
76
+ /** For a drift agent: the Op's job, which handed the drift on. */
77
+ after?: string;
78
+ }
79
+
80
+ /** The comment condition of the agent job: a pull request comment that starts with the agent's line, from a writer on GitHub. */
81
+ export function agentCommentIf(forge: "github" | "forgejo", comment: string): string {
82
+ return [
83
+ "github.event_name == 'issue_comment'",
84
+ forge === "forgejo" ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
85
+ `startsWith(github.event.comment.body, '${comment.replace(/'/g, "''")} ')`,
86
+ ...(forge === "forgejo" ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
87
+ ].join(" && ");
88
+ }
89
+
90
+ /**
91
+ * The drift Op's run step (#4616): the run with its output kept as the
92
+ * drift for the agent, `drift=true` among the step's outputs when it exits
93
+ * with chant's `drift` code, and the output handed on. The step still exits
94
+ * with the run's code.
95
+ */
96
+ export function driftRunStep(invocation: string, env: Record<string, string>, forge: "github" | "forgejo"): Record<string, unknown> {
97
+ return {
98
+ id: AGENT_DRIFT_STEP_ID,
99
+ env,
100
+ run: [
101
+ "set +e",
102
+ `mkdir -p ${AGENT_DIR}`,
103
+ ...handoverTrap(AGENT_DIR, forge, DRIFT_HANDED),
104
+ `{ ${invocation}; echo "$?" >"\${RUNNER_TEMP:-/tmp}/chant-run.code"; } 2>&1 | tee ${AGENT_DIR}/${AGENT_DRIFT_FILE}`,
105
+ 'code=$(cat "${RUNNER_TEMP:-/tmp}/chant-run.code")',
106
+ `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "drift=true" >>"$GITHUB_OUTPUT"; fi`,
107
+ 'exit "$code"',
108
+ ].join("\n"),
109
+ };
110
+ }
111
+
112
+ /** The drift Op job's outputs: the handed-on drift and the drift flag. */
113
+ export function driftRunOutputs(forge: "github" | "forgejo"): Record<string, string> {
114
+ return { ...handoverOutputs(AGENT_DRIFT_STEP_ID, forge), drift: `\${{ steps.${AGENT_DRIFT_STEP_ID}.outputs.drift }}` };
115
+ }
116
+
117
+ /** The agent job and the push job. */
118
+ export function githubAgentJobs(i: GithubAgentJobsInput): { agent: Record<string, unknown>; push: Record<string, unknown> } {
119
+ const s = i.settings;
120
+ const lines = agentCommandLines(i.forge, i.bin);
121
+ const drop = dropForgeTokens("sh");
122
+ const comment = s.on === "comment";
123
+ const settingsEnv = agentSettingsEnv(s);
124
+ const checkout = (head: boolean): Record<string, unknown> => ({
125
+ name: head ? "Check out the target branch's commit and the pull request's head" : "Check out the commit the drift check ran on",
126
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN, ...(head ? { PR_NUMBER: "${{ github.event.issue.number }}" } : {}) },
127
+ // The ledger branch too: prepare counts the agent's runs on it, and land appends to it.
128
+ run: [checkoutScript(true, true), ...(head ? [agentHeadFetchLine(i.forge, "PR_NUMBER")] : [])].join("\n"),
129
+ });
130
+ const before = i.beforeScript.map((line) => ({ run: `${drop}\n${line}` }));
131
+ const timeout = s.timeout + 15;
132
+ const agent: Record<string, unknown> = {
133
+ ...(comment ? { if: agentCommentIf(i.forge, s.comment!) } : { needs: i.after, if: `always() && needs.${i.after}.outputs.drift == 'true'` }),
134
+ "runs-on": githubRunsOn(i.runner),
135
+ container: i.image,
136
+ "timeout-minutes": timeout,
137
+ permissions: { contents: "read", "pull-requests": "read" },
138
+ env: settingsEnv,
139
+ outputs: handoverOutputs(AGENT_STEP_ID, i.forge),
140
+ steps: [
141
+ checkout(comment),
142
+ ...(comment ? [] : [{ name: "Take the drift from the drift check", run: takeOverScript(i.after!, AGENT_DIR, i.forge) }]),
143
+ ...i.setup,
144
+ ...before,
145
+ // Reads only: the comment, the commenter's access, the pull request's head and the ledger.
146
+ { name: "Decide whether the agent runs", env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN }, run: lines.prepare },
147
+ // The job's last step: the model's key and no forge token, and the agent directory handed on however it ends.
148
+ {
149
+ id: AGENT_STEP_ID,
150
+ name: "Run the agent",
151
+ env: { [s.keyEnv]: githubSecret(s.keyEnv) },
152
+ run: [drop, ...handoverTrap(AGENT_DIR, i.forge, HANDED), lines.run].join("\n"),
153
+ },
154
+ ],
155
+ };
156
+ const token = i.tokenEnv ? githubSecretOrJobToken(i.tokenEnv) : GITHUB_JOB_TOKEN;
157
+ const push: Record<string, unknown> = {
158
+ needs: i.jobName,
159
+ if: `always() && needs.${i.jobName}.result == 'success'`,
160
+ "runs-on": githubRunsOn(i.runner),
161
+ container: i.image,
162
+ permissions: { contents: "write", "pull-requests": "write", issues: "write" },
163
+ env: settingsEnv,
164
+ steps: [
165
+ checkout(comment),
166
+ { name: "Take the change from the agent job", run: takeOverScript(i.jobName, AGENT_DIR, i.forge) },
167
+ ...i.setup,
168
+ ...before,
169
+ {
170
+ name: comment ? "Push the change to the pull request" : "Open or update the agent's pull request",
171
+ env: { [PUSH_TOKEN_ENV]: token, GITHUB_TOKEN: token, ...(i.forge === "forgejo" ? { CHANT_FORGEJO_TOKEN: token } : {}) },
172
+ run: [...agentGitAuthLines(PUSH_TOKEN_ENV, "x-access-token"), lines.land].join("\n"),
173
+ },
174
+ ],
175
+ };
176
+ return { agent, push };
177
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The control repo's own workflow (#4568) on GitHub, and on Forgejo through
3
+ * its dialect: a member's dispatch starts it, and it runs the member's
4
+ * dependents with `chant workspace run --dependents-of`. The pieces are
5
+ * core's (`controlPipelineSpec` in `@intentius/chant/control/dispatch`);
6
+ * this file writes them as a workflow. The member's side, the
7
+ * `control-dispatch` job, comes from the pull request loop's `control`
8
+ * option (./generate-pipeline.ts).
9
+ */
10
+
11
+ import { CI_IMAGE, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
12
+ import { controlPipelineSpec } from "@intentius/chant/control/dispatch";
13
+ import type { CiProductWords, CiProductWordsInput } from "@intentius/chant/ci-product-words";
14
+ import { CHECKOUT, concurrency, emitWorkflowYAML, type GithubWorkflowDoc } from "./ci-jobs";
15
+
16
+ /** A control repo's pipeline file: where it goes, its text, and what a person still sets up. */
17
+ export interface ControlPipelineFile {
18
+ path: string;
19
+ content: string;
20
+ notes: string[];
21
+ }
22
+
23
+ /** What {@link controlGithubDoc} and the generators take. */
24
+ export interface ControlPipelineOptions {
25
+ words?: CiProductWordsInput | CiProductWords;
26
+ /** The image the job runs in. Default chant's CI image. */
27
+ image?: string;
28
+ /** The command the job runs chant with. Default `npx --no-install chant`. */
29
+ command?: string;
30
+ }
31
+
32
+ /** The control repo's workflow document for a GitHub-shaped forge, before emission. Forgejo transforms it first. */
33
+ export function controlGithubDoc(forge: "github" | "forgejo", options: ControlPipelineOptions = {}): { doc: GithubWorkflowDoc; path: string; notes: string[] } {
34
+ const spec = controlPipelineSpec(forge, options.words ?? {}, options.command ? { command: options.command } : {});
35
+ const doc: GithubWorkflowDoc = {
36
+ header: spec.header,
37
+ name: spec.path.split("/").pop()!.replace(/\.yml$/, ""),
38
+ on: {
39
+ ...(forge === "github" ? { repository_dispatch: { types: [spec.event] } } : {}),
40
+ workflow_dispatch: { inputs: spec.inputs },
41
+ },
42
+ // #4617: contents: write pushes the record of each dispatch that carries an outcome to chant/lifecycle.
43
+ permissions: { contents: "write" },
44
+ // One run per member at a time, and none cancelled: a later apply of the same member waits for the one running.
45
+ concurrency: concurrency(spec.group),
46
+ jobsDoc: {
47
+ dependents: {
48
+ "runs-on": GITHUB_RUNNER,
49
+ container: options.image ?? CI_IMAGE,
50
+ steps: [
51
+ // The full history, so the job reads and appends to chant/lifecycle (#4617).
52
+ { uses: CHECKOUT, with: { "fetch-depth": 0 } },
53
+ { name: "Run the dependents of the member that applied", shell: "bash", env: spec.env, run: spec.script },
54
+ ],
55
+ },
56
+ },
57
+ };
58
+ return { doc, path: spec.path, notes: spec.notes };
59
+ }
60
+
61
+ /** The control repo's workflow on GitHub: `.github/workflows/<product>-dependents.yml`. */
62
+ export function generateControlPipeline(options: ControlPipelineOptions = {}): ControlPipelineFile {
63
+ const { doc, path, notes } = controlGithubDoc("github", options);
64
+ return { path, content: emitWorkflowYAML(doc), notes };
65
+ }