@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
@@ -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,15 +64,27 @@
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
 
67
78
  import {
68
79
  notifyVariables,
69
80
  opWaveGroupOf,
70
81
  opWaveJobs,
82
+ opWaveJobTimeout,
71
83
  opWaveOutcomeDir,
72
84
  opWavesControlTarget,
73
85
  opWavesLastJobs,
86
+ opWavesApprovalStatusCommand,
87
+ opWavesPostsApprovalStatus,
74
88
  opWavesOutcomeCommand,
75
89
  opWavesRecordPlansCommand,
76
90
  opWavesResumeCommand,
@@ -93,6 +107,8 @@ import {
93
107
  setupStepDoc,
94
108
  } from "@intentius/chant/ci-jobs";
95
109
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
110
+ import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
111
+ import { resolveProductWords } from "@intentius/chant/ci-product-words";
96
112
  import { checkoutStep, concurrency, DOWNLOAD_ARTIFACT, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
97
113
  import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
98
114
 
@@ -113,10 +129,15 @@ export function opWaveOutcomeArtifact(spec: Pick<OpWavesSpec, "name">, jobName:
113
129
  export function buildGithubOpWavesDoc(
114
130
  spec: OpWavesSpec,
115
131
  options: OpWavesPipelineOptions,
132
+ dialect: { forge?: "github" | "forgejo" } = {},
116
133
  ): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"]; resumeDoc?: GithubOpPipelineDoc; plansDoc?: GithubOpPipelineDoc } {
117
134
  const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
135
+ const forge = dialect.forge ?? "github";
136
+ const product = resolveProductWords(options.words ?? {}).product;
118
137
  const image = options.image ?? OP_WAVES_IMAGE;
119
138
  const byReview = spec.waves.some((w) => w.approval === "pr-review");
139
+ // #4630: a pr-review or pull-request wave gets the plans workflow and its approval status.
140
+ const statusBeforeMerge = opWavesPostsApprovalStatus(spec);
120
141
  for (const wave of spec.waves) {
121
142
  assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
122
143
  if (wave.environment) assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
@@ -126,6 +147,8 @@ export function buildGithubOpWavesDoc(
126
147
  }
127
148
  }
128
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);
129
152
 
130
153
  const jobsDoc: Record<string, unknown> = {};
131
154
  for (const job of jobs) {
@@ -134,7 +157,21 @@ export function buildGithubOpWavesDoc(
134
157
  const group = opWaveGroupOf(spec, job);
135
158
  // #4332: the job's cloud identities, traded before the checkout.
136
159
  const cloud = group?.cloud ?? wave.cloud;
137
- const steps: Array<Record<string, unknown>> = [...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
160
+ const environment = group?.environment ?? wave.environment;
161
+ const idTokens = group?.idTokens ?? wave.idTokens;
162
+ const idToken = idTokens !== undefined || cloud !== undefined;
163
+ const runner = group?.runner ?? wave.runner ?? spec.runner;
164
+ // #4624: the guard first, before the trade and the checkout.
165
+ const guard = guardStep({
166
+ forge,
167
+ job: opWaveJobLabel(spec.name, job),
168
+ product,
169
+ ...(environment && forge === "github" ? { environment: environment.name } : {}),
170
+ branches: pushBranches(spec.branches),
171
+ ...(runner !== undefined ? { runner } : {}),
172
+ idToken,
173
+ });
174
+ const steps: Array<Record<string, unknown>> = [guard, ...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
138
175
  if (job.kind === "share") {
139
176
  steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
140
177
  }
@@ -151,11 +188,8 @@ export function buildGithubOpWavesDoc(
151
188
  steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
152
189
  }
153
190
  steps.push(keepRecordStep(`Keep the job's outcome`, opWaveOutcomeArtifact(spec, job.jobName), job.outcome));
154
- const environment = group?.environment ?? wave.environment;
155
- const idTokens = group?.idTokens ?? wave.idTokens;
156
- const idToken = idTokens !== undefined || cloud !== undefined;
157
191
  jobsDoc[job.jobName] = {
158
- "runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
192
+ "runs-on": githubRunsOn(runner),
159
193
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
160
194
  // #4322: a wave's jobs run after a failure in the wave before; a share job runs only on its deciding job's success.
161
195
  ...(job.needs.length > 0 && job.kind !== "share" ? { if: "!cancelled()" } : {}),
@@ -163,7 +197,8 @@ export function buildGithubOpWavesDoc(
163
197
  ...(environment ? { environment: environmentDoc(environment) } : {}),
164
198
  // #3914: a job's own permissions replace the workflow's, so they repeat them.
165
199
  ...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
166
- ...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) },
167
202
  steps,
168
203
  };
169
204
  }
@@ -180,7 +215,8 @@ export function buildGithubOpWavesDoc(
180
215
  name: spec.name,
181
216
  on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
182
217
  ...nonEmpty("env", env),
183
- 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.
184
220
  permissions,
185
221
  jobsDoc,
186
222
  };
@@ -188,7 +224,7 @@ export function buildGithubOpWavesDoc(
188
224
  doc,
189
225
  jobs,
190
226
  ...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}),
191
- ...(byReview ? { plansDoc: buildPlansDoc(spec, options) } : {}),
227
+ ...(statusBeforeMerge ? { plansDoc: buildPlansDoc(spec, options, forge) } : {}),
192
228
  };
193
229
  }
194
230
 
@@ -256,21 +292,55 @@ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Git
256
292
  };
257
293
  }
258
294
 
259
- /** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
260
- function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
295
+ /**
296
+ * The pull request workflow a `pr-review` wave needs (#3684): record the
297
+ * digests each head plans. The `approval-status` job after it (#4630) posts
298
+ * `<product>/approval` on the head, and runs alone on each review submitted
299
+ * or dismissed. It holds a read token that may write statuses and nothing
300
+ * else. A `pull-request` wave gets the same workflow: the record keeps its
301
+ * change set, and the status reads the pull request's own approval of it.
302
+ */
303
+ function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions, forge: "github" | "forgejo"): GithubOpPipelineDoc {
261
304
  const steps: Array<Record<string, unknown>> = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
262
305
  steps.push({ name: "Plan every wave at the head and record its digests", run: opWavesRecordPlansCommand(options.specFile, options.bin).join(" ") });
306
+ const statusSteps: Array<Record<string, unknown>> = [checkoutStep(true), ...runSteps(options.beforeScript ?? [])];
307
+ statusSteps.push({
308
+ name: "Post the approval status on the head",
309
+ run: opWavesApprovalStatusCommand(options.specFile, forge, options.bin).join(" "),
310
+ env: {
311
+ GITHUB_TOKEN: GITHUB_JOB_TOKEN,
312
+ PR_NUMBER: "${{ github.event.pull_request.number }}",
313
+ HEAD_SHA: "${{ github.event.pull_request.head.sha }}",
314
+ BASE_SHA: "${{ github.event.pull_request.base.sha }}",
315
+ },
316
+ });
263
317
  return {
264
318
  name: `${spec.name}-plans`,
265
- on: { pull_request: { branches: pushBranches(spec.branches) } },
319
+ on: { pull_request: { branches: pushBranches(spec.branches) }, pull_request_review: { types: ["submitted", "dismissed"] } },
266
320
  ...nonEmpty("env", options.variables),
267
- concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
321
+ concurrency: concurrency(spec.name + "-plans-${{ github.event_name }}-${{ github.event.pull_request.number }}", true),
268
322
  // contents: write pushes the record to chant/lifecycle.
269
323
  permissions: { contents: "write" },
270
- jobsDoc: { "record-plans": { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
324
+ jobsDoc: {
325
+ "record-plans": { if: "github.event_name == 'pull_request'", "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps },
326
+ "approval-status": {
327
+ needs: ["record-plans"],
328
+ // On a review the record-plans job is skipped, and the status reads the plans an earlier push recorded.
329
+ if: "${{ always() && (needs.record-plans.result == 'success' || needs.record-plans.result == 'skipped') }}",
330
+ "runs-on": githubRunsOn(spec.runner),
331
+ container: options.image ?? OP_WAVES_IMAGE,
332
+ permissions: { contents: "read", "pull-requests": "read", statuses: "write" },
333
+ steps: statusSteps,
334
+ },
335
+ },
271
336
  };
272
337
  }
273
338
 
339
+ /** The apply-job guard step (#4624): the job's expectation in CHANT_JOB_GUARD, checked by `node -e` from `/`. */
340
+ function guardStep(input: Parameters<typeof jobGuardSpec>[0]): Record<string, unknown> {
341
+ return { name: "Check the job runs where the spec set it to run", env: { [JOB_GUARD_ENV]: JSON.stringify(jobGuardSpec(input)) }, run: jobGuardLine() };
342
+ }
343
+
274
344
  /** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
275
345
  function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
276
346
  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") };
@@ -59,7 +59,7 @@
59
59
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
60
60
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
61
61
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
62
- import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
62
+ import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER, githubRunsOn } from "@intentius/chant/ci-jobs";
63
63
  import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
64
64
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
65
65
  import {
@@ -78,7 +78,7 @@ 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,
@@ -87,6 +87,10 @@ import {
87
87
  prLoopWaveJobs,
88
88
  prLoopWithWaves,
89
89
  prEnvJobs,
90
+ prLoopJobStage,
91
+ prStaleJob,
92
+ PR_STALE_JOB,
93
+ prLoopRunner,
90
94
  PR_ENV_REPORT_DIR,
91
95
  PR_ENV_SWEEP_CRON,
92
96
  PR_ENV_POST_JOB,
@@ -415,7 +419,25 @@ function prLoopGithubDoc(
415
419
  },
416
420
  ]);
417
421
  }
418
- const post = prLoopPostJob(forgeName, env, member?.name, loop.words ? resolveProductWords(loop.words) : undefined);
422
+ 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 } : {}) });
423
+ // #4628: on a push to the target branch, the open pull requests' notes posted against another commit are marked stale.
424
+ const stale = prStaleJob(forgeName, env, loop, member?.name);
425
+ const staleJobs: Record<string, unknown> = stale
426
+ ? {
427
+ [PR_STALE_JOB]: {
428
+ if: "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')",
429
+ "runs-on": GITHUB_RUNNER,
430
+ container: image,
431
+ permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
432
+ steps: [
433
+ // The pushed commit: the target branch's, so none of a pull request's code is here.
434
+ { name: "Check out the target branch's commit", uses: CHECKOUT },
435
+ ...runSteps(beforeScript),
436
+ { name: "Mark the open pull requests' notes stale", ...(rooted ? {} : { "working-directory": "." }), env: stale.env, run: stale.command },
437
+ ],
438
+ },
439
+ }
440
+ : {};
419
441
  // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
420
442
  const controlRead = loop.control ? controlNoteCheckout(forgeName, loop.control.target, loop.words ?? {}) : undefined;
421
443
  const noteSteps: Array<Record<string, unknown>> = [
@@ -431,6 +453,8 @@ function prLoopGithubDoc(
431
453
  const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
432
454
  // #4327: with ci.locks: plan the pull_request trigger fires on close too, for the unlock job, and the plan skips that event.
433
455
  const locks = loop.locks === "plan";
456
+ // plywood#22: the lock lines run through the product's bin, as its other job lines do.
457
+ const lockBin = resolveProductWords(loop.words).bin;
434
458
  const closes = Boolean(prEnv) || locks;
435
459
  const lockJobs: Record<string, unknown> = locks
436
460
  ? {
@@ -449,7 +473,7 @@ function prLoopGithubDoc(
449
473
  {
450
474
  name: "Lock the components the plan selected",
451
475
  env: { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.event.pull_request.head.sha }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN },
452
- run: prLockCommand(forgeName),
476
+ run: prLockCommand(forgeName, lockBin),
453
477
  },
454
478
  ],
455
479
  },
@@ -461,7 +485,7 @@ function prLoopGithubDoc(
461
485
  steps: [
462
486
  { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
463
487
  ...runSteps(beforeScript),
464
- { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: PR_UNLOCK_COMMAND },
488
+ { name: "Release the pull request's locks", env: { PR_NUMBER: "${{ github.event.pull_request.number }}" }, run: prUnlockCommand(lockBin) },
465
489
  ],
466
490
  },
467
491
  }
@@ -563,6 +587,7 @@ function prLoopGithubDoc(
563
587
  ...lockJobs,
564
588
  ...commentLockJobs,
565
589
  ...previewJobs,
590
+ ...staleJobs,
566
591
  ...Object.fromEntries(applyJobs),
567
592
  ...Object.fromEntries(controlJobs),
568
593
  ...(prEnv ? prEnv.jobs : {}),
@@ -580,6 +605,12 @@ function prLoopGithubDoc(
580
605
  }
581
606
  // #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
582
607
  if (loop.applyBeforeMerge || commentLocks.length > 0 || preview) on.issue_comment = { types: ["created"] };
608
+ // #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
609
+ if (loop.runners) {
610
+ for (const [name, job] of Object.entries(jobsDoc) as Array<[string, Record<string, unknown>]>) {
611
+ job["runs-on"] = githubRunsOn(prLoopRunner(loop, prLoopJobStage(name)));
612
+ }
613
+ }
583
614
  return {
584
615
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
585
616
  environment: env,
@@ -587,10 +618,11 @@ function prLoopGithubDoc(
587
618
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
588
619
  env: { ...options.variables, CHANT_ENV: env },
589
620
  jobsDoc,
590
- 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"] : [])],
621
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
591
622
  jobs: [
592
623
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
593
624
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
625
+ ...(stale ? [{ jobName: PR_STALE_JOB, component: "pull request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
594
626
  ...(locks
595
627
  ? [
596
628
  { jobName: PR_LOCK_JOB, component: "pull request lock", stage: PR_LOCK_JOB, needs: ["plan"] },
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The preview comment's --target on GitHub (#4626): the preview step runs
3
+ * `pr-plan --comment-targets` with the comment body in CHANT_PREVIEW_COMMENT,
4
+ * and the body never appears in the shell line. Written for the
5
+ * ci-products post-merge pass.
6
+ */
7
+ import { expect, test } from "vitest";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ import type { DriverComponent } from "@intentius/chant/components/driver";
10
+ import { generateGithubPipeline } from "./generate-pipeline";
11
+
12
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
13
+ const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
14
+
15
+ test("the preview step reads the comment from a variable (#4626)", () => {
16
+ const doc = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as { jobs: Record<string, { steps: Array<{ name?: string; run?: string; env?: Record<string, string> }> }> };
17
+ const step = doc.jobs["comment-preview"]!.steps.find((s) => s.name === "Preview the members this pull request reaches")!;
18
+ expect(step.run).toContain("npx --no-install plywood ci run plan");
19
+ expect(step.run).toContain("--comment-targets");
20
+ expect(step.env!.CHANT_PREVIEW_COMMENT).toBe("${{ github.event.comment.body }}");
21
+ expect(step.run).not.toContain("comment.body");
22
+ const plan = doc.jobs.plan!.steps.find((s) => s.name === "Plan the members this pull request reaches")!;
23
+ expect(plan.run).not.toContain("--comment-targets");
24
+ });
@@ -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 });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The pull request loop's responses, description check and merge on GitHub
3
+ * (#4629, #4636), as `chant build --pr-loop --respond --decide-backend
4
+ * --decide-model --merge auto` sets them: the note job asks the decide
5
+ * service, and the apply job carries CHANT_RESPOND and the merge.
6
+ */
7
+ import { describe, expect, test } from "vitest";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ import type { DriverComponent } from "@intentius/chant/components/driver";
10
+ import { generateGithubPipeline } from "./generate-pipeline";
11
+
12
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
13
+
14
+ type Step = { name?: string; run?: string; env?: Record<string, string> };
15
+ type Doc = { jobs: Record<string, { steps: Step[] }> };
16
+ const gen = (prLoop: Record<string, unknown>) => parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml) as Doc;
17
+
18
+ describe("responses and the merge on GitHub (#4629, #4636)", () => {
19
+ const doc = gen({
20
+ applyBeforeMerge: true,
21
+ applyMerge: { tokenEnv: "CHANT_MERGE_TOKEN", method: "squash" },
22
+ respond: { "wave-refused": "off", description: "check" },
23
+ decide: { backend: "review", model: "m-1" },
24
+ });
25
+
26
+ test("the note job carries the responses and asks the decide service", () => {
27
+ const post = doc.jobs["plan-note"]!.steps.at(-1)!;
28
+ expect(post.run).toMatch(/--decide-backend "review" --decide-model "m-1"$/);
29
+ expect(post.env!.CHANT_RESPOND).toBe("wave-refused=off,description=check");
30
+ });
31
+
32
+ test("the apply job carries the responses and the merge", () => {
33
+ const steps = Object.values(doc.jobs).flatMap((j) => j.steps);
34
+ const apply = steps.find((s) => s.run?.includes("--apply-before-merge"))!;
35
+ expect(apply.run).toContain("--merge auto --merge-token-env CHANT_MERGE_TOKEN --merge-method squash");
36
+ expect(apply.env!.CHANT_RESPOND).toBe("wave-refused=off,description=check");
37
+ expect(apply.env!.CHANT_MERGE_TOKEN).toBe("${{ secrets.CHANT_MERGE_TOKEN }}");
38
+ });
39
+
40
+ test("without them, neither job changes", () => {
41
+ const plain = gen({ applyBeforeMerge: true });
42
+ const post = plain.jobs["plan-note"]!.steps.at(-1)!;
43
+ expect(post.run).not.toContain("--decide-backend");
44
+ expect(post.env!.CHANT_RESPOND).toBeUndefined();
45
+ expect(JSON.stringify(plain)).not.toContain("--merge auto");
46
+ });
47
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The pull request loop's runners, variables and passed secrets on GitHub
3
+ * (#4625): every job on its stage's runner, and the jobs that plan or apply
4
+ * with the variables and the secrets mapped from the repository's. Written
5
+ * for the ci-products post-merge pass.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { DriverComponent } from "@intentius/chant/components/driver";
11
+ import { generateGithubPipeline } from "./generate-pipeline";
12
+
13
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
14
+ const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const, lock: "lock" as const, unlock: "unlock" as const } };
15
+
16
+ type Step = { name?: string; run?: string; env?: Record<string, string> };
17
+ type Doc = { jobs: Record<string, { "runs-on": unknown; steps: Step[] }> };
18
+
19
+ const doc = parseYAML(
20
+ generateGithubPipeline(ESTATE, {
21
+ env: "prod",
22
+ prLoop: {
23
+ words: WORDS,
24
+ previewComment: true,
25
+ locks: "plan",
26
+ applyOnMerge: true,
27
+ runners: { plan: ["self-hosted", "big"], apply: "deployer", default: "small" },
28
+ variables: { REGION: "eu-west-1" },
29
+ secrets: { DB_PASSWORD: "PROD_DB_PASSWORD" },
30
+ parallelism: 2,
31
+ },
32
+ }).yaml,
33
+ ) as Doc;
34
+
35
+ describe("the GitHub pull request loop's runners and variables (#4625)", () => {
36
+ test("every job runs on its stage's runner", () => {
37
+ expect(doc.jobs.plan!["runs-on"]).toEqual(["self-hosted", "big"]);
38
+ expect(doc.jobs["comment-preview"]!["runs-on"]).toEqual(["self-hosted", "big"]);
39
+ expect(doc.jobs.apply!["runs-on"]).toBe("deployer");
40
+ expect(doc.jobs.record!["runs-on"]).toBe("deployer");
41
+ for (const name of ["plan-note", "comment-preview-note", "lock", "unlock", "comment-lock", "comment-unlock"]) expect(doc.jobs[name]!["runs-on"], name).toBe("small");
42
+ });
43
+
44
+ test("the plan step gets the variable and the secret, and still drops the forge tokens first", () => {
45
+ const step = doc.jobs.plan!.steps.find((s) => s.name === "Plan the members this pull request reaches")!;
46
+ expect(step.env).toMatchObject({ REGION: "eu-west-1", DB_PASSWORD: "${{ secrets.PROD_DB_PASSWORD }}" });
47
+ expect(step.env).not.toHaveProperty("GITHUB_TOKEN");
48
+ expect(step.run!.split("\n")[0]).toContain("unset");
49
+ expect(step.run).toContain("--parallelism 2");
50
+ });
51
+
52
+ test("the apply step gets them too", () => {
53
+ const step = doc.jobs.apply!.steps.find((s) => s.run?.includes("ci run apply"))!;
54
+ expect(step.env).toMatchObject({ REGION: "eu-west-1", DB_PASSWORD: "${{ secrets.PROD_DB_PASSWORD }}" });
55
+ });
56
+
57
+ test("without runners every job keeps the forge's default", () => {
58
+ const plain = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: {} }).yaml) as Doc;
59
+ expect(plain.jobs.plan!["runs-on"]).toBe("ubuntu-latest");
60
+ });
61
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Stale marking on GitHub (#4628): with staleNotes the note job passes the
3
+ * target branch's commit, and a stale-notes job on a push to the branch
4
+ * runs `pr-post --stale` at the pushed commit with a token that may edit the
5
+ * notes and set the statuses. Written for the ci-products post-merge pass.
6
+ */
7
+ import { describe, expect, test } from "vitest";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ import type { DriverComponent } from "@intentius/chant/components/driver";
10
+ import { generateGithubPipeline } from "./generate-pipeline";
11
+
12
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
13
+ const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
14
+
15
+ type Step = { name?: string; run?: string; env?: Record<string, string>; uses?: string };
16
+ type Doc = { jobs: Record<string, { if?: string; permissions?: Record<string, string>; steps: Step[] }> };
17
+ const gen = (prLoop: Record<string, unknown>) => parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml) as Doc;
18
+
19
+ describe("stale notes on GitHub (#4628)", () => {
20
+ const doc = gen({ words: WORDS, staleNotes: true, previewComment: true });
21
+
22
+ test("the note job passes the target branch's commit", () => {
23
+ const post = doc.jobs["plan-note"]!.steps.at(-1)!;
24
+ expect(post.run).toContain('--tip "$TIP_SHA"');
25
+ expect(post.env!.TIP_SHA).toBe("${{ github.event.pull_request.base.sha }}");
26
+ });
27
+
28
+ test("a stale-notes job on a push marks the open notes stale, naming the preview comment", () => {
29
+ const job = doc.jobs["stale-notes"]!;
30
+ expect(job.if).toBe("github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')");
31
+ expect(job.permissions).toEqual({ contents: "read", "pull-requests": "write", statuses: "write" });
32
+ const step = job.steps.at(-1)!;
33
+ expect(step.run).toBe('npx --no-install plywood components pr-post --stale --tip "$TIP_SHA" --env prod --forge github --preview-comment');
34
+ expect(step.env).toEqual({ TIP_SHA: "${{ github.sha }}", GITHUB_TOKEN: "${{ github.token }}" });
35
+ });
36
+
37
+ test("without staleNotes, no job and no tip", () => {
38
+ const plain = gen({ words: WORDS });
39
+ expect(plain.jobs["stale-notes"]).toBeUndefined();
40
+ expect(plain.jobs["plan-note"]!.steps.at(-1)!.run).not.toContain("--tip");
41
+ });
42
+ });