@intentius/chant-lexicon-github 0.124.55 → 0.124.57

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 (25) hide show
  1. package/dist/components/generate-op-pipeline.d.ts +2 -2
  2. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  3. package/dist/components/generate-op-pipeline.js +73 -8
  4. package/dist/components/generate-op-waves-pipeline.d.ts +16 -3
  5. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  6. package/dist/components/generate-op-waves-pipeline.js +82 -17
  7. package/dist/components/generate-pipeline.d.ts.map +1 -1
  8. package/dist/components/generate-pipeline.js +34 -6
  9. package/dist/integrity.json +2 -2
  10. package/dist/manifest.json +1 -1
  11. package/package.json +2 -2
  12. package/src/components/__golden__/op-waves-jobs.yml +31 -4
  13. package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
  14. package/src/components/generate-op-pipeline.ts +89 -15
  15. package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
  16. package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
  17. package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
  18. package/src/components/generate-op-waves-pipeline.test.ts +19 -3
  19. package/src/components/generate-op-waves-pipeline.ts +85 -15
  20. package/src/components/generate-pipeline.ts +38 -6
  21. package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
  22. package/src/components/generate-pr-pipeline.locks.test.ts +10 -0
  23. package/src/components/generate-pr-pipeline.respond.test.ts +47 -0
  24. package/src/components/generate-pr-pipeline.runners.test.ts +61 -0
  25. package/src/components/generate-pr-pipeline.stale-notes.test.ts +42 -0
@@ -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;AAOlC,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,CAgL1D;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"}
@@ -77,6 +77,9 @@ import { assertReviewSplit, reviewJobs, reviewNoteJobName } from "./review-jobs.
77
77
  import { resolveAgentSpec } from "@intentius/chant/ci-agent";
78
78
  import { activeProductWords } from "@intentius/chant/ci-product-words";
79
79
  import { agentJobName, agentPushJobName, driftRunOutputs, driftRunStep, githubAgentJobs } from "./agent-jobs.js";
80
+ import { assertScheduledOpCloud, scheduledOpUpdateJobName, withoutTokenRequestLine } from "@intentius/chant/scheduled-op-cloud";
81
+ import { TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
82
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
80
83
  // The setup, environment and permission checks are shared with the Op waves
81
84
  // generator and the forgejo lexicon (#3765); they stay importable from here.
82
85
  export { assertEnvironment, assertSetupSteps, mergePermissions };
@@ -313,6 +316,8 @@ export function buildGithubOpPipelineDocs(ops, options = {}, forge = "github") {
313
316
  const jobs = [];
314
317
  const words = activeProductWords();
315
318
  for (const spec of ops) {
319
+ // #4631: the job's cloud identities and the update on drift, checked before anything renders.
320
+ assertScheduledOpCloud(spec);
316
321
  // #4616: the comment agent is a workflow of its own, on pull request comments; the spec's trigger is not read.
317
322
  if (spec.agent?.on === "comment") {
318
323
  const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
@@ -364,6 +369,8 @@ export function buildGithubOpPipelineDocs(ops, options = {}, forge = "github") {
364
369
  throw new Error(`Scheduled Op "${spec.name}" sets a drift agent, which follows a drift check's run; give it a cron or pull_request trigger and no reviewAgent.`);
365
370
  }
366
371
  const stepEnv = split ? {} : jobTokenEnv(findingMode !== "report");
372
+ const cloud = spec.cloud;
373
+ const update = spec.update;
367
374
  const setup = spec.setup ?? [];
368
375
  assertSetupSteps(spec.name, setup);
369
376
  if (spec.environment)
@@ -376,13 +383,17 @@ export function buildGithubOpPipelineDocs(ops, options = {}, forge = "github") {
376
383
  const invocation = gated
377
384
  ? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
378
385
  : runParts.join(" ");
379
- const steps = [checkoutStep(), ...setup.map(setupStepDoc), ...runSteps(beforeScript)];
380
- steps.push(gated
386
+ const later = [...setup.map(setupStepDoc), ...runSteps(beforeScript)];
387
+ later.push(gated
381
388
  ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
382
389
  : driftAgent
383
390
  ? driftRunStep(invocation, stepEnv, forge)
384
- : { run: invocation, env: stepEnv });
385
- steps.push(...runSteps(extraScript));
391
+ : update
392
+ ? driftFlagStep(invocation, stepEnv)
393
+ : { run: invocation, env: stepEnv });
394
+ later.push(...runSteps(extraScript));
395
+ // #4631: the trade before the checkout, and every step after it without the token request variables.
396
+ const steps = cloud ? [tradeStep(cloud), checkoutStep(), ...later.map(withoutTokenRequest)] : [checkoutStep(), ...later];
386
397
  const review = split
387
398
  ? reviewJobs({
388
399
  name: spec.name,
@@ -396,6 +407,7 @@ export function buildGithubOpPipelineDocs(ops, options = {}, forge = "github") {
396
407
  ...(options.member ? { member: options.member } : {}),
397
408
  })
398
409
  : undefined;
410
+ const workflowPermissions = mergePermissions(spec.name, permissionsFor(findingMode, trigger), spec.permissions ?? {}, trigger);
399
411
  const doc = {
400
412
  name: spec.name,
401
413
  on: onTrigger(trigger),
@@ -403,7 +415,7 @@ export function buildGithubOpPipelineDocs(ops, options = {}, forge = "github") {
403
415
  // One run at a time per Op — a slow audit must not overlap its own next
404
416
  // scheduled trigger. A push Op's `build` (#4169) widens the group.
405
417
  concurrency: concurrency(opConcurrencyGroup(jobName, trigger)),
406
- permissions: mergePermissions(spec.name, permissionsFor(findingMode, trigger), spec.permissions ?? {}, trigger),
418
+ permissions: workflowPermissions,
407
419
  jobsDoc: {
408
420
  [jobName]: {
409
421
  "runs-on": githubRunsOn(spec.runner),
@@ -418,6 +430,8 @@ export function buildGithubOpPipelineDocs(ops, options = {}, forge = "github") {
418
430
  // behind the same reviewer would hold the message back until
419
431
  // somebody had already acted.
420
432
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
433
+ // #4631: the trade needs id-token: write, on this job alone; the finding-mode's scopes stay as they are.
434
+ ...(cloud ? { permissions: { ...workflowPermissions, "id-token": "write" } } : {}),
421
435
  ...(gated
422
436
  ? {
423
437
  outputs: Object.fromEntries(["gated", "op", "gate", "approve"].map((name) => [
@@ -427,9 +441,21 @@ export function buildGithubOpPipelineDocs(ops, options = {}, forge = "github") {
427
441
  }
428
442
  : driftAgent
429
443
  ? { outputs: driftRunOutputs(forge) }
430
- : {}),
444
+ : update
445
+ ? { outputs: { drift: `\${{ steps.${DRIFT_FLAG_STEP_ID}.outputs.drift }}` } }
446
+ : {}),
431
447
  steps,
432
448
  },
449
+ ...(update
450
+ ? {
451
+ [scheduledOpUpdateJobName(jobName)]: updateJob(jobName, update, {
452
+ image,
453
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
454
+ setup: setup.map(setupStepDoc),
455
+ beforeScript,
456
+ }),
457
+ }
458
+ : {}),
433
459
  },
434
460
  ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image, spec.runner) } } : {}),
435
461
  };
@@ -487,14 +513,14 @@ function scopeOpFileToMember(file, member) {
487
513
  const { steps, ...rest } = props;
488
514
  return [job, { ...rest, defaults: { run: { "working-directory": member.dir } }, steps }];
489
515
  }));
490
- const group = file.doc.concurrency.group;
516
+ const group = file.doc.concurrency?.group;
491
517
  return {
492
518
  name,
493
519
  doc: {
494
520
  ...file.doc,
495
521
  ...(file.doc.name ? { name: `${member.name}/${file.doc.name}` } : {}),
496
522
  on,
497
- 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}` } : {}) } } : {}),
498
524
  jobsDoc,
499
525
  },
500
526
  };
@@ -524,3 +550,42 @@ export function generateGithubOpPipeline(ops, options = {}) {
524
550
  jobs,
525
551
  };
526
552
  }
553
+ /** The id of the run step that says whether an update's drift check found drift (#4631). */
554
+ const DRIFT_FLAG_STEP_ID = "drift-check";
555
+ /** The Op's run step when an update follows it (#4631): `drift=true` as an output on chant's drift code, and the run's own exit code. */
556
+ function driftFlagStep(invocation, env) {
557
+ return {
558
+ id: DRIFT_FLAG_STEP_ID,
559
+ env,
560
+ run: ["set +e", invocation, "code=$?", `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "drift=true" >>"$GITHUB_OUTPUT"; fi`, 'exit "$code"'].join("\n"),
561
+ };
562
+ }
563
+ /** The trade step (#4631), as an op wave job's (#4332): the job's OIDC token for `cloud`, written to GITHUB_ENV. */
564
+ function tradeStep(cloud) {
565
+ return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
566
+ }
567
+ /** A run step after the trade, with the token request variables unset first. A `uses:` step is left as it is. */
568
+ function withoutTokenRequest(step) {
569
+ return typeof step.run === "string" ? { ...step, run: withoutTokenRequestLine(step.run) } : step;
570
+ }
571
+ /**
572
+ * The update job (#4631): after the Op's job, only when its run found
573
+ * drift; its own trade for `update.cloud` before its checkout, then the
574
+ * update's command. Its permissions are `contents: read`, `id-token: write`
575
+ * with a cloud, and the update's own scopes, which may raise `contents` to
576
+ * `write` so the update records on `chant/lifecycle` (2026-10-28 ruling).
577
+ * The Op's own job keeps its scopes.
578
+ */
579
+ function updateJob(after, update, i) {
580
+ const later = [...i.setup, ...runSteps(i.beforeScript), { name: "Update what drifted", run: update.command }];
581
+ return {
582
+ needs: after,
583
+ if: `always() && needs.${after}.outputs.drift == 'true'`,
584
+ "runs-on": githubRunsOn(i.runner),
585
+ container: i.image,
586
+ permissions: { contents: "read", ...(update.cloud ? { "id-token": "write" } : {}), ...(update.permissions ?? {}) },
587
+ ...nonEmpty("env", update.variables),
588
+ ...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
589
+ steps: update.cloud ? [tradeStep(update.cloud), checkoutStep(), ...later.map(withoutTokenRequest)] : [checkoutStep(), ...later],
590
+ };
591
+ }
@@ -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
  *
@@ -34,6 +34,8 @@
34
34
  * `pull-requests: read`, so a wave can read the merged pull request's
35
35
  * reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
36
36
  * --record-plans` on each pull request to record the digests its head plans.
37
+ * A `pull-request` wave (#4630) gets the plans workflow too, for the approval
38
+ * status before merge; it asks for no `pull-requests: read` on the waves.
37
39
  *
38
40
  * With `notify` on the spec (#3761), the workflow maps each variable it names
39
41
  * from the repository secret of the same name, so a wave job can post to
@@ -62,6 +64,15 @@
62
64
  * step holds the control token: the `tokenSecret` secret, mapped into
63
65
  * `CHANT_CONTROL_TOKEN` and handed to curl on its stdin
64
66
  * (`controlDispatchScript`, #4568).
67
+ *
68
+ * Every wave, deciding and share job starts with the apply-job guard
69
+ * (#4624, `@intentius/chant/ci-job-guard`), before the trade and the
70
+ * checkout: it stops the job unless `GITHUB_REF` is one of the spec's
71
+ * branches, the job runs in its forge environment (read from an OIDC
72
+ * token's claims, so only in a job that may request one), and, for a
73
+ * runner whose labels include `self-hosted`, on a self-hosted runner. The
74
+ * forgejo generator passes `forge: "forgejo"`, which checks the ref and
75
+ * runner only.
65
76
  */
66
77
  import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
67
78
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
@@ -73,7 +84,9 @@ export declare function opWaveRecordArtifact(spec: Pick<OpWavesSpec, "name">, wa
73
84
  /** The artifact a job keeps its outcome file in (#4322). */
74
85
  export declare function opWaveOutcomeArtifact(spec: Pick<OpWavesSpec, "name">, jobName: string): string;
75
86
  /** The workflow document, before emission, and the jobs it holds. */
76
- export declare function buildGithubOpWavesDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): {
87
+ export declare function buildGithubOpWavesDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions, dialect?: {
88
+ forge?: "github" | "forgejo";
89
+ }): {
77
90
  doc: GithubOpPipelineDoc;
78
91
  jobs: OpWavesPipelineResult["jobs"];
79
92
  resumeDoc?: GithubOpPipelineDoc;
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAEH,OAAO,EAUL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAiB9F,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,GAC9B;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,CA6EpI;AAkGD,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
  *
@@ -34,6 +34,8 @@
34
34
  * `pull-requests: read`, so a wave can read the merged pull request's
35
35
  * reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
36
36
  * --record-plans` on each pull request to record the digests its head plans.
37
+ * A `pull-request` wave (#4630) gets the plans workflow too, for the approval
38
+ * status before merge; it asks for no `pull-requests: read` on the waves.
37
39
  *
38
40
  * With `notify` on the spec (#3761), the workflow maps each variable it names
39
41
  * from the repository secret of the same name, so a wave job can post to
@@ -62,11 +64,22 @@
62
64
  * step holds the control token: the `tokenSecret` secret, mapped into
63
65
  * `CHANT_CONTROL_TOKEN` and handed to curl on its stdin
64
66
  * (`controlDispatchScript`, #4568).
67
+ *
68
+ * Every wave, deciding and share job starts with the apply-job guard
69
+ * (#4624, `@intentius/chant/ci-job-guard`), before the trade and the
70
+ * checkout: it stops the job unless `GITHUB_REF` is one of the spec's
71
+ * branches, the job runs in its forge environment (read from an OIDC
72
+ * token's claims, so only in a job that may request one), and, for a
73
+ * runner whose labels include `self-hosted`, on a self-hosted runner. The
74
+ * forgejo generator passes `forge: "forgejo"`, which checks the ref and
75
+ * runner only.
65
76
  */
66
- import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, 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";
67
78
  import { CONTROL_OUTCOME_VAR, controlDispatchJob } from "@intentius/chant/control/dispatch";
68
79
  import { assertEnvironment, assertSetupSteps, CI_IMAGE, dirnameOf, environmentDoc, GITHUB_JOB_TOKEN, githubRunsOn, githubSecretEnv, idTokenAudienceEnv, nonEmpty, pushBranches, setupStepDoc, } from "@intentius/chant/ci-jobs";
69
80
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
81
+ import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
82
+ import { resolveProductWords } from "@intentius/chant/ci-product-words";
70
83
  import { checkoutStep, concurrency, DOWNLOAD_ARTIFACT, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs.js";
71
84
  import { emitOpPipelineYAML } from "./generate-op-pipeline.js";
72
85
  /** The default image: it carries git, which the runner needs to read the base commit. */
@@ -80,10 +93,14 @@ export function opWaveOutcomeArtifact(spec, jobName) {
80
93
  return `${spec.name}-${jobName}-outcome`;
81
94
  }
82
95
  /** The workflow document, before emission, and the jobs it holds. */
83
- export function buildGithubOpWavesDoc(spec, options) {
96
+ export function buildGithubOpWavesDoc(spec, options, dialect = {}) {
84
97
  const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
98
+ const forge = dialect.forge ?? "github";
99
+ const product = resolveProductWords(options.words ?? {}).product;
85
100
  const image = options.image ?? OP_WAVES_IMAGE;
86
101
  const byReview = spec.waves.some((w) => w.approval === "pr-review");
102
+ // #4630: a pr-review or pull-request wave gets the plans workflow and its approval status.
103
+ const statusBeforeMerge = opWavesPostsApprovalStatus(spec);
87
104
  for (const wave of spec.waves) {
88
105
  assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
89
106
  if (wave.environment)
@@ -95,6 +112,8 @@ export function buildGithubOpWavesDoc(spec, options) {
95
112
  }
96
113
  }
97
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);
98
117
  const jobsDoc = {};
99
118
  for (const job of jobs) {
100
119
  const wave = spec.waves[job.wave - 1];
@@ -102,7 +121,21 @@ export function buildGithubOpWavesDoc(spec, options) {
102
121
  const group = opWaveGroupOf(spec, job);
103
122
  // #4332: the job's cloud identities, traded before the checkout.
104
123
  const cloud = group?.cloud ?? wave.cloud;
105
- const steps = [...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
124
+ const environment = group?.environment ?? wave.environment;
125
+ const idTokens = group?.idTokens ?? wave.idTokens;
126
+ const idToken = idTokens !== undefined || cloud !== undefined;
127
+ const runner = group?.runner ?? wave.runner ?? spec.runner;
128
+ // #4624: the guard first, before the trade and the checkout.
129
+ const guard = guardStep({
130
+ forge,
131
+ job: opWaveJobLabel(spec.name, job),
132
+ product,
133
+ ...(environment && forge === "github" ? { environment: environment.name } : {}),
134
+ branches: pushBranches(spec.branches),
135
+ ...(runner !== undefined ? { runner } : {}),
136
+ idToken,
137
+ });
138
+ const steps = [guard, ...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
106
139
  if (job.kind === "share") {
107
140
  steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record)));
108
141
  }
@@ -119,11 +152,8 @@ export function buildGithubOpWavesDoc(spec, options) {
119
152
  steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record));
120
153
  }
121
154
  steps.push(keepRecordStep(`Keep the job's outcome`, opWaveOutcomeArtifact(spec, job.jobName), job.outcome));
122
- const environment = group?.environment ?? wave.environment;
123
- const idTokens = group?.idTokens ?? wave.idTokens;
124
- const idToken = idTokens !== undefined || cloud !== undefined;
125
155
  jobsDoc[job.jobName] = {
126
- "runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
156
+ "runs-on": githubRunsOn(runner),
127
157
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
128
158
  // #4322: a wave's jobs run after a failure in the wave before; a share job runs only on its deciding job's success.
129
159
  ...(job.needs.length > 0 && job.kind !== "share" ? { if: "!cancelled()" } : {}),
@@ -131,7 +161,8 @@ export function buildGithubOpWavesDoc(spec, options) {
131
161
  ...(environment ? { environment: environmentDoc(environment) } : {}),
132
162
  // #3914: a job's own permissions replace the workflow's, so they repeat them.
133
163
  ...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
134
- ...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) },
135
166
  steps,
136
167
  };
137
168
  }
@@ -146,7 +177,8 @@ export function buildGithubOpWavesDoc(spec, options) {
146
177
  name: spec.name,
147
178
  on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
148
179
  ...nonEmpty("env", env),
149
- 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.
150
182
  permissions,
151
183
  jobsDoc,
152
184
  };
@@ -154,7 +186,7 @@ export function buildGithubOpWavesDoc(spec, options) {
154
186
  doc,
155
187
  jobs,
156
188
  ...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}),
157
- ...(byReview ? { plansDoc: buildPlansDoc(spec, options) } : {}),
189
+ ...(statusBeforeMerge ? { plansDoc: buildPlansDoc(spec, options, forge) } : {}),
158
190
  };
159
191
  }
160
192
  /**
@@ -213,20 +245,53 @@ function buildResumeDoc(spec, options) {
213
245
  jobsDoc: { resume: { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
214
246
  };
215
247
  }
216
- /** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
217
- function buildPlansDoc(spec, options) {
248
+ /**
249
+ * The pull request workflow a `pr-review` wave needs (#3684): record the
250
+ * digests each head plans. The `approval-status` job after it (#4630) posts
251
+ * `<product>/approval` on the head, and runs alone on each review submitted
252
+ * or dismissed. It holds a read token that may write statuses and nothing
253
+ * else. A `pull-request` wave gets the same workflow: the record keeps its
254
+ * change set, and the status reads the pull request's own approval of it.
255
+ */
256
+ function buildPlansDoc(spec, options, forge) {
218
257
  const steps = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
219
258
  steps.push({ name: "Plan every wave at the head and record its digests", run: opWavesRecordPlansCommand(options.specFile, options.bin).join(" ") });
259
+ const statusSteps = [checkoutStep(true), ...runSteps(options.beforeScript ?? [])];
260
+ statusSteps.push({
261
+ name: "Post the approval status on the head",
262
+ run: opWavesApprovalStatusCommand(options.specFile, forge, options.bin).join(" "),
263
+ env: {
264
+ GITHUB_TOKEN: GITHUB_JOB_TOKEN,
265
+ PR_NUMBER: "${{ github.event.pull_request.number }}",
266
+ HEAD_SHA: "${{ github.event.pull_request.head.sha }}",
267
+ BASE_SHA: "${{ github.event.pull_request.base.sha }}",
268
+ },
269
+ });
220
270
  return {
221
271
  name: `${spec.name}-plans`,
222
- on: { pull_request: { branches: pushBranches(spec.branches) } },
272
+ on: { pull_request: { branches: pushBranches(spec.branches) }, pull_request_review: { types: ["submitted", "dismissed"] } },
223
273
  ...nonEmpty("env", options.variables),
224
- concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
274
+ concurrency: concurrency(spec.name + "-plans-${{ github.event_name }}-${{ github.event.pull_request.number }}", true),
225
275
  // contents: write pushes the record to chant/lifecycle.
226
276
  permissions: { contents: "write" },
227
- jobsDoc: { "record-plans": { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
277
+ jobsDoc: {
278
+ "record-plans": { if: "github.event_name == 'pull_request'", "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps },
279
+ "approval-status": {
280
+ needs: ["record-plans"],
281
+ // On a review the record-plans job is skipped, and the status reads the plans an earlier push recorded.
282
+ if: "${{ always() && (needs.record-plans.result == 'success' || needs.record-plans.result == 'skipped') }}",
283
+ "runs-on": githubRunsOn(spec.runner),
284
+ container: options.image ?? OP_WAVES_IMAGE,
285
+ permissions: { contents: "read", "pull-requests": "read", statuses: "write" },
286
+ steps: statusSteps,
287
+ },
288
+ },
228
289
  };
229
290
  }
291
+ /** The apply-job guard step (#4624): the job's expectation in CHANT_JOB_GUARD, checked by `node -e` from `/`. */
292
+ function guardStep(input) {
293
+ return { name: "Check the job runs where the spec set it to run", env: { [JOB_GUARD_ENV]: JSON.stringify(jobGuardSpec(input)) }, run: jobGuardLine() };
294
+ }
230
295
  /** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
231
296
  function tradeStep(cloud) {
232
297
  return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
@@ -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;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"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA2CjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAkoBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
@@ -58,10 +58,10 @@
58
58
  import { resolveComponentGraph } from "@intentius/chant/components/driver";
59
59
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
60
60
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
61
- import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
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, 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, 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";
@@ -322,7 +322,25 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
322
322
  },
323
323
  ]);
324
324
  }
325
- const post = prLoopPostJob(forgeName, env, member?.name, loop.words ? resolveProductWords(loop.words) : undefined);
325
+ const post = prLoopPostJob(forgeName, env, member?.name, loop.words ? resolveProductWords(loop.words) : undefined, { ...(loop.staleNotes ? { staleNotes: true } : {}), ...(loop.respond ? { respond: loop.respond } : {}), ...(loop.decide ? { decide: loop.decide } : {}) });
326
+ // #4628: on a push to the target branch, the open pull requests' notes posted against another commit are marked stale.
327
+ const stale = prStaleJob(forgeName, env, loop, member?.name);
328
+ const staleJobs = stale
329
+ ? {
330
+ [PR_STALE_JOB]: {
331
+ if: "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')",
332
+ "runs-on": GITHUB_RUNNER,
333
+ container: image,
334
+ permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
335
+ steps: [
336
+ // The pushed commit: the target branch's, so none of a pull request's code is here.
337
+ { name: "Check out the target branch's commit", uses: CHECKOUT },
338
+ ...runSteps(beforeScript),
339
+ { name: "Mark the open pull requests' notes stale", ...(rooted ? {} : { "working-directory": "." }), env: stale.env, run: stale.command },
340
+ ],
341
+ },
342
+ }
343
+ : {};
326
344
  // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
327
345
  const controlRead = loop.control ? controlNoteCheckout(forgeName, loop.control.target, loop.words ?? {}) : undefined;
328
346
  const noteSteps = [
@@ -338,6 +356,8 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
338
356
  const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
339
357
  // #4327: with ci.locks: plan the pull_request trigger fires on close too, for the unlock job, and the plan skips that event.
340
358
  const locks = loop.locks === "plan";
359
+ // plywood#22: the lock lines run through the product's bin, as its other job lines do.
360
+ const lockBin = resolveProductWords(loop.words).bin;
341
361
  const closes = Boolean(prEnv) || locks;
342
362
  const lockJobs = locks
343
363
  ? {
@@ -356,7 +376,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
356
376
  {
357
377
  name: "Lock the components the plan selected",
358
378
  env: { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.event.pull_request.head.sha }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN },
359
- run: prLockCommand(forgeName),
379
+ run: prLockCommand(forgeName, lockBin),
360
380
  },
361
381
  ],
362
382
  },
@@ -368,7 +388,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
368
388
  steps: [
369
389
  { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
370
390
  ...runSteps(beforeScript),
371
- { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: PR_UNLOCK_COMMAND },
391
+ { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: prUnlockCommand(lockBin) },
372
392
  ],
373
393
  },
374
394
  }
@@ -468,6 +488,7 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
468
488
  ...lockJobs,
469
489
  ...commentLockJobs,
470
490
  ...previewJobs,
491
+ ...staleJobs,
471
492
  ...Object.fromEntries(applyJobs),
472
493
  ...Object.fromEntries(controlJobs),
473
494
  ...(prEnv ? prEnv.jobs : {}),
@@ -487,6 +508,12 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
487
508
  // #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
488
509
  if (loop.applyBeforeMerge || commentLocks.length > 0 || preview)
489
510
  on.issue_comment = { types: ["created"] };
511
+ // #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
512
+ if (loop.runners) {
513
+ for (const [name, job] of Object.entries(jobsDoc)) {
514
+ job["runs-on"] = githubRunsOn(prLoopRunner(loop, prLoopJobStage(name)));
515
+ }
516
+ }
490
517
  return {
491
518
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
492
519
  environment: env,
@@ -494,10 +521,11 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
494
521
  ...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
495
522
  env: { ...options.variables, CHANT_ENV: env },
496
523
  jobsDoc,
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"] : [])],
524
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
498
525
  jobs: [
499
526
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
500
527
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
528
+ ...(stale ? [{ jobName: PR_STALE_JOB, component: "pull request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
501
529
  ...(locks
502
530
  ? [
503
531
  { jobName: PR_LOCK_JOB, component: "pull request lock", stage: PR_LOCK_JOB, needs: ["plan"] },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "3513bb8a86e9d8cf394877a70b42741598681301a7c892fa94a65856cd1742ac",
4
+ "manifest.json": "eab8cbe3ae719023202bcc5ffdbbbd5ca8dd0d263d22ca405024ff9991cf07b6",
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": "d2ef289d1dabdd39c05090b1dc9e881fa69fd0934c0cb3a197d8ca9abb4f5a45"
81
+ "composite": "c581b6a6d25abd873cb8aba19d5ccd648c178312acdbdac07a30900b9b685b5a"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.55",
3
+ "version": "0.124.57",
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.55",
3
+ "version": "0.124.57",
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.55"
64
+ "@intentius/chant": "^0.124.57"
65
65
  }
66
66
  }