@intentius/chant-lexicon-github 0.124.40 → 0.124.42

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.
@@ -59,8 +59,11 @@
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_RUNNER } from "@intentius/chant/ci-jobs";
62
+ import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
63
+ import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
64
+ import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
63
65
  import {
66
+ APPLY_COMMENT,
64
67
  GREEN_TAG_PATTERN,
65
68
  GREEN_TICK_WORKFLOW,
66
69
  PR_APPLY_RECORD,
@@ -70,9 +73,15 @@ import {
70
73
  prApplyRecordKey,
71
74
  prApplyWaveJobName,
72
75
  prLoopJobs,
76
+ prLoopPostJob,
73
77
  prLoopSetup,
78
+ PR_PLAN_NOTE_JOB,
74
79
  prLoopWaveJobs,
75
80
  prLoopWithWaves,
81
+ prEnvJobs,
82
+ PR_ENV_REPORT_DIR,
83
+ PR_ENV_SWEEP_CRON,
84
+ PR_ENV_POST_JOB,
76
85
  } from "@intentius/chant/components/pr-pipeline";
77
86
  import { memberRepoPath } from "@intentius/chant/lexicon";
78
87
  import type {
@@ -81,7 +90,8 @@ import type {
81
90
  ComponentPipelineResult as GenerateGithubResult,
82
91
  PipelineMember,
83
92
  } from "@intentius/chant/lexicon";
84
- import { checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs";
93
+ import { afterEarlierWave, checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs";
94
+ import { actionRef } from "../action-pins";
85
95
 
86
96
  export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
87
97
 
@@ -122,6 +132,9 @@ export interface GithubPipelineDoc {
122
132
  jobs: GeneratedJob[];
123
133
  }
124
134
 
135
+ /** The plan job's last step, whose outputs carry its report to the plan-note job (#4295). */
136
+ const PLAN_STEP_ID = "plan";
137
+
125
138
  /** The output a gated apply wave hands the green commit it applies through, and the step that sets it (#4221). */
126
139
  const WAVE_GREEN_OUTPUT = "green-sha";
127
140
  const WAVE_STEP_ID = "apply";
@@ -158,6 +171,8 @@ function gatedWaveGithubDoc(
158
171
  steps.push(...runSteps(prLoopSetup(image)), ...runSteps(beforeScript), { run: job.command.join(" ") }, ...runSteps(extraScript));
159
172
  steps.push(keepRecordStep(`Upload the record for wave ${job.wave}`, waveRecordArtifact(job.wave), GATED_WAVE_RECORD));
160
173
  jobsDoc[job.jobName] = {
174
+ // #4291: a wave runs after one that failed, and blocks only what depends on the failure.
175
+ ...(job.needs.length > 0 ? { if: afterEarlierWave(job.needs[0]!) } : {}),
161
176
  "runs-on": GITHUB_RUNNER,
162
177
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
163
178
  container: image,
@@ -203,6 +218,35 @@ function gatedWaveGithubDoc(
203
218
  * for the pushed commit, so wave k reads what wave k-1 left, and a re-run of
204
219
  * a wave that stopped at its gate reads what it left itself. Each keeps its
205
220
  * report as `chant-pr-apply-wave-<k>`.
221
+ *
222
+ * The plan job holds no forge token while the pull request's code runs
223
+ * (#4295, `@intentius/chant/ci-handover`). It checks the change out with a
224
+ * script that gives the job's token to its fetches alone, starts every later
225
+ * step again without the token variables, and ends with the plan step, which
226
+ * hands its report on as the job's outputs. The `plan-note` job then checks
227
+ * out the target branch's commit, takes the report back, keeps it as the
228
+ * `chant-pr-plan` artifact, and posts the note and `chant/plan` with
229
+ * `chant components pr-post`. Only the note job may write to the pull
230
+ * request and its statuses.
231
+ *
232
+ * With `identity` (#4296, `@intentius/chant/ci-identity`) the plan, record
233
+ * and apply jobs get `id-token: write`, and each starts with a step that
234
+ * trades the job's OIDC token for its cloud identity before anything is
235
+ * checked out: the plan and record jobs for the plan identity, the apply
236
+ * jobs for the apply identity. The trade leaves the credentials in
237
+ * `GITHUB_ENV`. Every later step of the plan job starts without the
238
+ * variables a token is requested with, so the pull request's code holds no
239
+ * OIDC token and cannot request one, and the plan job names no apply
240
+ * identity.
241
+ *
242
+ * With `applyBeforeMerge` (#4301, ws-106) the apply job runs on a
243
+ * `/chant apply` comment on a pull request (`issue_comment`), from the
244
+ * default branch's workflow file, so a pull request cannot change the job
245
+ * that applies it. It checks out the pull request's head after the setup
246
+ * and `beforeScript` ran at the default branch's commit, and runs `pr-apply
247
+ * --before-merge`, which asks the forge the requires checks before any of
248
+ * the head's code loads. On GitHub the commenter must be an owner, member
249
+ * or collaborator. A push still records.
206
250
  */
207
251
  function prLoopGithubDoc(
208
252
  components: DriverComponent[],
@@ -235,8 +279,18 @@ function prLoopGithubDoc(
235
279
  uses: "actions/cache/save@v4",
236
280
  with: { path: recordPath, key: recordKey(wave) },
237
281
  });
282
+ // #4296: the step that trades the job's OIDC token for its cloud identity, before the checkout.
283
+ const identity = loop.identity;
284
+ const tradeStep = (spec: CloudTradeSpec, which: "plan" | "apply"): Record<string, unknown> => ({
285
+ name: `Trade the job's OIDC token for the ${which} identity`,
286
+ ...(rooted ? {} : { "working-directory": "." }),
287
+ env: { [TRADE_SPEC_ENV]: JSON.stringify(spec) },
288
+ run: tradeLine("$GITHUB_ENV", "github-env"),
289
+ });
290
+ const withIdToken = (permissions: Record<string, string>): Record<string, string> => (identity ? { ...permissions, "id-token": "write" } : permissions);
238
291
  const reportName = (job: typeof plan) => `chant-pr-${job.wave ? prApplyWaveJobName(job.wave) : job.jobName}`;
239
292
  const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
293
+ ...(identity ? [tradeStep(job.jobName === "record" ? identity.plan : identity.apply, job.jobName === "record" ? "plan" : "apply")] : []),
240
294
  checkoutStep(true),
241
295
  ...runSteps(prLoopSetup(image)),
242
296
  ...runSteps(beforeScript),
@@ -266,13 +320,22 @@ function prLoopGithubDoc(
266
320
  const forgejo = loop.forge === "forgejo";
267
321
  const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : "github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'";
268
322
  const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
323
+ // #4301: a `/chant apply` comment on a pull request starts the apply before merge. GitHub also asks for a commenter who can write.
324
+ const onComment = [
325
+ "github.event_name == 'issue_comment'",
326
+ // Forgejo's payload says so with is_pull (terragucci comment-apply.ts:129 at e6cb431).
327
+ forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
328
+ `startsWith(github.event.comment.body, '${APPLY_COMMENT}')`,
329
+ ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
330
+ ].join(" && ");
269
331
  const applyJob = (job: typeof plan, needs: string[], name: string) => ({
270
- if: job.jobName === "record" ? onPush : onGreen,
332
+ // #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
333
+ if: job.jobName === "record" ? onPush : needs.length > 0 && job.wave ? afterEarlierWave(needs[0]!, onGreen) : onGreen,
271
334
  "runs-on": GITHUB_RUNNER,
272
335
  ...(needs.length > 0 ? { needs } : {}),
273
336
  container: image,
274
337
  concurrency: concurrency(prApplyGroup(env, member?.name)),
275
- permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
338
+ permissions: withIdToken({ contents: "write", "pull-requests": "write", statuses: "write" }),
276
339
  ...(job.wave ? { outputs: { [WAVE_GREEN_OUTPUT]: `\${{ steps.${WAVE_STEP_ID}.outputs.${WAVE_GREEN_OUTPUT} }}` } } : {}),
277
340
  steps: steps(job, name),
278
341
  });
@@ -285,44 +348,211 @@ function prLoopGithubDoc(
285
348
  ])
286
349
  : loop.applyOnMerge
287
350
  ? [["apply", applyJob(apply, [], "Apply the newest green commit under the merged changes' approvals")]]
288
- : [];
351
+ : loop.applyBeforeMerge
352
+ ? [["apply", { ...applyJob(apply, [], "Apply the pull request's head under its gate, once it is approved, mergeable, undiverged and green"), if: onComment }]]
353
+ : [];
289
354
  const applyJobs: Array<[string, Record<string, unknown>]> = [recordJob, ...greenJobs];
355
+ // #4295: the plan job runs the pull request's code and holds no forge token while it does.
356
+ const forgeName = loop.forge ?? "github";
357
+ // #4296: once the plan job has traded its OIDC token, its later steps start without the variables a token is requested with.
358
+ const drop = dropForgeTokens("sh", identity ? OIDC_REQUEST_VARS : []);
359
+ const headStep = (line: string, name?: string): Record<string, unknown> => ({ ...(name ? { name } : {}), run: `${drop}\n${line}` });
360
+ const planSteps: Array<Record<string, unknown>> = [
361
+ // #4296: the trade runs first, before the change is checked out, with the plan identity alone.
362
+ ...(identity ? [tradeStep(identity.plan, "plan")] : []),
363
+ // A script, not actions/checkout, whose post step runs at the job's end with the token as its input.
364
+ {
365
+ name: "Check out the change",
366
+ ...(rooted ? {} : { "working-directory": "." }),
367
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
368
+ run: checkoutScript(true, true),
369
+ },
370
+ ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
371
+ ...(plan.build ? [headStep(plan.build[0]!, "Build the templates"), headStep(plan.build[1]!, "Lint the project")] : []),
372
+ // The job's last step: a step after it would start beside whatever the change's code left running.
373
+ {
374
+ name: "Plan the members this pull request reaches",
375
+ id: PLAN_STEP_ID,
376
+ env: plan.run.env,
377
+ run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), ...(plan.checkout ?? []), ...(plan.setup ?? []), plan.run.command, ...extraScript].join("\n"),
378
+ },
379
+ ];
380
+ const post = prLoopPostJob(forgeName, env, member?.name);
381
+ const noteSteps: Array<Record<string, unknown>> = [
382
+ // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
383
+ { name: "Check out the target branch's commit", uses: actionRef("actions/checkout"), with: { ref: "${{ github.event.pull_request.base.sha }}" } },
384
+ ...runSteps(beforeScript),
385
+ { name: "Take the plan report from the plan job", run: takeOverScript("plan", PR_LOOP_REPORT_DIR, forgeName) },
386
+ { 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 } },
387
+ { name: "Post the plan note and chant/plan", if: "always()", env: post.env, run: post.command },
388
+ ];
389
+ // #4298: with a pull request environment the pull_request trigger fires on close too, and the plan skips that event.
390
+ const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
290
391
  const jobsDoc: Record<string, unknown> = {
291
392
  plan: {
292
- if: "github.event_name == 'pull_request'",
393
+ if: prEnv ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
293
394
  "runs-on": GITHUB_RUNNER,
294
395
  container: image,
295
- permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
296
- steps: steps(plan, "Plan the members this pull request reaches"),
396
+ permissions: withIdToken({ contents: "read" }),
397
+ outputs: handoverOutputs(PLAN_STEP_ID, forgeName),
398
+ steps: planSteps,
399
+ },
400
+ [PR_PLAN_NOTE_JOB]: {
401
+ needs: ["plan"],
402
+ if: "always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')",
403
+ "runs-on": GITHUB_RUNNER,
404
+ container: image,
405
+ // #4289: `actions: read` lets pr-post ask when each scheduled watch or reconcile Op last ran.
406
+ permissions: { actions: "read", contents: "read", "pull-requests": "write", statuses: "write" },
407
+ steps: noteSteps,
297
408
  },
298
409
  ...Object.fromEntries(applyJobs),
410
+ ...(prEnv ? prEnv.jobs : {}),
299
411
  };
300
412
  const applyNames = applyJobs.map(([name]) => name);
413
+ const on: Record<string, unknown> = !loop.applyOnMerge
414
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch] } }
415
+ : forgejo
416
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch], tags: [GREEN_TAG_PATTERN] } }
417
+ : { pull_request: { branches: [branch] }, push: { branches: [branch] }, workflow_run: { workflows: [GREEN_TICK_WORKFLOW], types: ["completed"] }, workflow_dispatch: {} };
418
+ if (prEnv) {
419
+ on.pull_request = { branches: [branch], types: ["opened", "synchronize", "reopened", "closed"] };
420
+ on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
421
+ }
422
+ // #4301: a /chant apply comment starts the apply before merge.
423
+ if (loop.applyBeforeMerge) on.issue_comment = { types: ["created"] };
301
424
  return {
302
425
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
303
426
  environment: env,
304
- on: !loop.applyOnMerge
305
- ? { pull_request: { branches: [branch] }, push: { branches: [branch] } }
306
- : forgejo
307
- ? { pull_request: { branches: [branch] }, push: { branches: [branch], tags: [GREEN_TAG_PATTERN] } }
308
- : { pull_request: { branches: [branch] }, push: { branches: [branch] }, workflow_run: { workflows: [GREEN_TICK_WORKFLOW], types: ["completed"] }, workflow_dispatch: {} },
427
+ on,
309
428
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
310
429
  env: { ...options.variables, CHANT_ENV: env },
311
430
  jobsDoc,
312
- stages: ["plan", ...applyNames],
431
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...applyNames, ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
313
432
  jobs: [
314
433
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
434
+ { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
315
435
  ...applyNames.map((name, i) => ({
316
436
  jobName: name,
317
- component: name === "record" ? "merge record" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
437
+ component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
318
438
  stage: name,
319
439
  // The record runs on a push and the apply on a green commit, so only the waves need each other.
320
440
  needs: i > 1 ? [applyNames[i - 1]] : [],
321
441
  })),
442
+ ...(prEnv ? prEnv.listed : []),
322
443
  ],
323
444
  };
324
445
  }
325
446
 
447
+ /**
448
+ * The jobs of a pull request's environment (#4298). `env-up` runs on an
449
+ * opened, updated or reopened pull request and runs the change's code, so it
450
+ * follows the plan job's handover (#4295): a checkout script that alone sees
451
+ * the job's token, every later step started without the token variables,
452
+ * and the `pr-env up` step last, handing its report and record on as the
453
+ * job's outputs. `env-post` checks out the target branch's commit, takes
454
+ * them back, keeps the record on chant/lifecycle and posts the note.
455
+ * `env-down` runs when the pull request closes and `env-sweep` on the
456
+ * schedule, both from the base branch's code: a teardown reads the markers
457
+ * off the live resources and builds nothing of the change. `env-up` and
458
+ * `env-down` run one at a time per pull request, and a close waits for an
459
+ * apply still running rather than cancelling it.
460
+ */
461
+ function prEnvGithubJobs(
462
+ forge: "github" | "forgejo",
463
+ image: string,
464
+ beforeScript: string[],
465
+ extraScript: string[],
466
+ loop: NonNullable<GenerateGithubOptions["prLoop"]>,
467
+ member: GenerateGithubOptions["member"],
468
+ ): { jobs: Record<string, unknown>; listed: GeneratedJob[] } {
469
+ const [up, post, down, sweep] = prEnvJobs(forge, loop, member?.name);
470
+ const rooted = !member || member.dir === "." || member.dir === "";
471
+ const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
472
+ const prNumber = "${{ github.event.pull_request.number }}";
473
+ const group = `chant-pr-env-${member ? `${member.name}-` : ""}${prNumber}`;
474
+ const writes = { contents: "write", "pull-requests": "write", statuses: "write" };
475
+ const keep = (name: string) => ({ name: `Keep the ${name} report`, if: "always()", uses: UPLOAD_ARTIFACT, with: { name: `chant-pr-${name}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } });
476
+ const opened = "github.event_name == 'pull_request' && github.event.action != 'closed'";
477
+
478
+ // #4295: env-up runs the change's code and holds no forge token while it does.
479
+ const drop = dropForgeTokens("sh");
480
+ const headStep = (line: string, name?: string, env?: Record<string, string>): Record<string, unknown> => ({ ...(name ? { name } : {}), ...(env ? { env } : {}), run: `${drop}\n${line}` });
481
+ const upJob = {
482
+ if: opened,
483
+ "runs-on": GITHUB_RUNNER,
484
+ container: image,
485
+ concurrency: concurrency(group),
486
+ permissions: { contents: "read" },
487
+ outputs: handoverOutputs(PR_ENV_UP_STEP_ID, forge),
488
+ steps: [
489
+ {
490
+ name: "Check out the change",
491
+ ...(rooted ? {} : { "working-directory": "." }),
492
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
493
+ run: checkoutScript(true, true),
494
+ },
495
+ ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
496
+ ...(up.build ? [headStep(up.build[0]!, "Build the templates", { CHANT_ENV: up.env.CHANT_ENV! }), headStep(up.build[1]!, "Lint the project")] : []),
497
+ // The job's last step: a step after it would start beside whatever the change's code left running.
498
+ {
499
+ name: "Apply this pull request's environment once its plan is approved",
500
+ id: PR_ENV_UP_STEP_ID,
501
+ env: up.env,
502
+ run: [drop, ...handoverTrap(PR_ENV_REPORT_DIR, forge), up.command, ...extraScript].join("\n"),
503
+ },
504
+ ],
505
+ };
506
+ const postJob = {
507
+ needs: ["env-up"],
508
+ if: `always() && ${opened} && (needs.env-up.result == 'success' || needs.env-up.result == 'failure')`,
509
+ "runs-on": GITHUB_RUNNER,
510
+ container: image,
511
+ concurrency: concurrency(`chant-pr-env-post-${member ? `${member.name}-` : ""}${prNumber}`),
512
+ permissions: writes,
513
+ steps: [
514
+ // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
515
+ { name: "Check out the target branch's commit", uses: actionRef("actions/checkout"), with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
516
+ ...runSteps(beforeScript),
517
+ { name: "Take the environment's report from the env-up job", run: takeOverScript("env-up", PR_ENV_REPORT_DIR, forge) },
518
+ keep("env-up"),
519
+ { name: "Keep the environment's record and post its note", if: "always()", env: post.env, run: post.command },
520
+ ],
521
+ };
522
+ const baseJob = (j: typeof up, condition: string, ref: string, name: string, groupName: string) => ({
523
+ if: condition,
524
+ "runs-on": GITHUB_RUNNER,
525
+ container: image,
526
+ concurrency: concurrency(groupName),
527
+ permissions: writes,
528
+ steps: [
529
+ { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
530
+ ...runSteps(prLoopSetup(image)),
531
+ ...runSteps(beforeScript),
532
+ { name, env: j.env, run: j.command },
533
+ ...runSteps(extraScript),
534
+ keep(j.jobName),
535
+ ],
536
+ });
537
+ return {
538
+ jobs: {
539
+ "env-up": upJob,
540
+ [PR_ENV_POST_JOB]: postJob,
541
+ "env-down": baseJob(down, "github.event_name == 'pull_request' && github.event.action == 'closed'", "${{ github.event.pull_request.base.ref }}", "Tear down what this pull request's environment owns", group),
542
+ "env-sweep": baseJob(sweep, "github.event_name == 'schedule'", "", "Tear down the environments whose TTL passed or whose pull request closed", `chant-pr-env-sweep${member ? `-${member.name}` : ""}`),
543
+ },
544
+ listed: [
545
+ { jobName: "env-up", component: "pull request environment", stage: "env-up", needs: [] },
546
+ { jobName: PR_ENV_POST_JOB, component: "pull request environment note", stage: PR_ENV_POST_JOB, needs: ["env-up"] },
547
+ { jobName: "env-down", component: "pull request environment teardown", stage: "env-down", needs: [] },
548
+ { jobName: "env-sweep", component: "pull request environment sweep", stage: "env-sweep", needs: [] },
549
+ ],
550
+ };
551
+ }
552
+
553
+ /** The id of the `pr-env up` step, whose outputs carry the report (#4295). */
554
+ const PR_ENV_UP_STEP_ID = "env-up";
555
+
326
556
  /** The file a producer dumps its resolved outputs to, and a dependent seeds from — one per component, matching gitlab's naming so both generators produce interchangeable artifacts. */
327
557
  function outputsFile(name: string): string {
328
558
  return `${name}.outputs.json`;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * A pull request's environment in the generated GitHub workflow (#4298):
3
+ * `env-up` on an opened, updated or reopened pull request, `env-post` after
4
+ * it, `env-down` when it closes, `env-sweep` on the schedule, and a plan job
5
+ * that skips the close.
6
+ *
7
+ * `env-up` runs the pull request's code, so it follows the handover (#4295):
8
+ * only its checkout sees the job's token, every later step starts without
9
+ * the token variables, and the `pr-env up` step is last and hands its report
10
+ * and record on. The behaviour test runs the generated steps with `sh`, as a
11
+ * forge runs them, with every token variable set.
12
+ */
13
+
14
+ import { execFileSync, spawnSync } from "node:child_process";
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { afterAll, describe, expect, test } from "vitest";
19
+ import { parseYAML } from "@intentius/chant/yaml";
20
+ import type { DriverComponent } from "@intentius/chant/components/driver";
21
+ import { dropForgeTokens, FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
22
+ import { PR_ENV_SWEEP_CRON } from "@intentius/chant/components/pr-pipeline";
23
+ import { generateGithubPipeline } from "./generate-pipeline";
24
+
25
+ const ESTATE: DriverComponent[] = [{ name: "app", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "kubectl-apply", manifest: "app.yaml" } as never] }] }];
26
+
27
+ interface Step {
28
+ name?: string;
29
+ id?: string;
30
+ if?: string;
31
+ uses?: string;
32
+ run?: string;
33
+ env?: Record<string, string>;
34
+ with?: Record<string, unknown>;
35
+ }
36
+ interface Job {
37
+ if: string;
38
+ needs?: string[];
39
+ permissions: Record<string, string>;
40
+ outputs?: Record<string, string>;
41
+ concurrency: { group: string; "cancel-in-progress": boolean };
42
+ steps: Step[];
43
+ }
44
+
45
+ const dirs: string[] = [];
46
+ afterAll(() => {
47
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
48
+ });
49
+
50
+ const SECRET = "ghs_generated_env_secret_4298";
51
+ const TOKENS = Object.fromEntries(FORGE_TOKEN_VARS.map((v) => [v, `${SECRET}_${v}`]));
52
+ const BASE = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" };
53
+ const UP_LINE = /^chant components pr-env up .*$/m;
54
+
55
+ function hasActionlint(): boolean {
56
+ try {
57
+ execFileSync("actionlint", ["-version"], { stdio: "ignore" });
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function workflow(): { yaml: string; jobs: string[]; doc: { on: Record<string, unknown>; jobs: Record<string, Job> } } {
65
+ const result = generateGithubPipeline(ESTATE, { env: "staging", image: "example/ci:1", beforeScript: ["npm ci"], extraScript: ["echo after"], prLoop: { prEnvironment: true, build: true } });
66
+ return { yaml: result.yaml, jobs: result.jobs.map((j) => j.jobName), doc: parseYAML(result.yaml) as { on: Record<string, unknown>; jobs: Record<string, Job> } };
67
+ }
68
+
69
+ describe("the GitHub pull-request workflow with a pull request environment", () => {
70
+ const { yaml, jobs, doc } = workflow();
71
+
72
+ test("the pull_request trigger fires on close too, and a schedule starts the sweep", () => {
73
+ expect(doc.on.pull_request).toEqual({ branches: ["main"], types: ["opened", "synchronize", "reopened", "closed"] });
74
+ expect(doc.on.schedule).toEqual([{ cron: PR_ENV_SWEEP_CRON }]);
75
+ expect(doc.jobs.plan!.if).toBe("github.event_name == 'pull_request' && github.event.action != 'closed'");
76
+ expect(jobs).toEqual(expect.arrayContaining(["env-up", "env-post", "env-down", "env-sweep"]));
77
+ });
78
+
79
+ test("env-up plans pr-<n> with no forge and no push; env-down tears it down from the base branch", () => {
80
+ const up = doc.jobs["env-up"]!;
81
+ const down = doc.jobs["env-down"]!;
82
+ expect(up.if).toBe("github.event_name == 'pull_request' && github.event.action != 'closed'");
83
+ expect(down.if).toBe("github.event_name == 'pull_request' && github.event.action == 'closed'");
84
+ const last = up.steps.at(-1)!;
85
+ expect(last.run).toMatch(UP_LINE);
86
+ expect(last.run!.match(UP_LINE)![0]).toBe('chant components pr-env up --base "$BASE_SHA" --pr "$PR_NUMBER" --output .chant/pr-env --no-push');
87
+ expect(last.env?.CHANT_ENV).toBe("pr-${{ github.event.pull_request.number }}");
88
+ const downRun = down.steps.find((s) => s.run?.startsWith("chant components pr-env"))!;
89
+ expect(downRun.run).toBe('chant components pr-env down --pr "$PR_NUMBER" --reason closed --forge github --output .chant/pr-env');
90
+ expect(down.steps[0]!.with?.ref).toBe("${{ github.event.pull_request.base.ref }}");
91
+ expect(up.concurrency).toEqual({ group: "chant-pr-env-${{ github.event.pull_request.number }}", "cancel-in-progress": false });
92
+ expect(down.concurrency).toEqual(up.concurrency);
93
+ expect(doc.jobs["env-sweep"]!.if).toBe("github.event_name == 'schedule'");
94
+ });
95
+
96
+ test("without the option nothing changes", () => {
97
+ const plain = parseYAML(generateGithubPipeline(ESTATE, { env: "staging", prLoop: {} }).yaml) as { on: Record<string, unknown>; jobs: Record<string, Job> };
98
+ expect(plain.on.pull_request).toEqual({ branches: ["main"] });
99
+ expect(plain.jobs["env-up"]).toBeUndefined();
100
+ });
101
+
102
+ test.skipIf(!hasActionlint())("passes actionlint", () => {
103
+ const dir = mkdtempSync(join(tmpdir(), "chant-actionlint-"));
104
+ dirs.push(dir);
105
+ mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
106
+ writeFileSync(join(dir, ".github", "workflows", "chant-pr.yml"), yaml);
107
+ expect(() => execFileSync("actionlint", [".github/workflows/chant-pr.yml"], { cwd: dir, encoding: "utf8" })).not.toThrow();
108
+ });
109
+ });
110
+
111
+ describe("env-up holds no forge token while the pull request's code runs (#4295)", () => {
112
+ const { doc } = workflow();
113
+ const up = doc.jobs["env-up"]!;
114
+ const post = doc.jobs["env-post"]!;
115
+
116
+ test("only the checkout step is given the job's token, and only for its fetches", () => {
117
+ const [checkout, ...rest] = up.steps;
118
+ expect(checkout!.uses).toBeUndefined();
119
+ expect(checkout!.env).toEqual({ CHANT_CHECKOUT_TOKEN: "${{ github.token }}" });
120
+ for (const step of rest) {
121
+ expect(JSON.stringify(step)).not.toContain("github.token");
122
+ for (const name of FORGE_TOKEN_VARS) expect(step.env ?? {}).not.toHaveProperty(name);
123
+ }
124
+ expect(up.permissions).toEqual({ contents: "read" });
125
+ });
126
+
127
+ test("every step after the checkout starts by dropping the token variables, and none is an action", () => {
128
+ for (const step of up.steps.slice(1)) {
129
+ expect(step.uses).toBeUndefined();
130
+ expect(step.run!.split("\n")[0]).toBe(dropForgeTokens("sh"));
131
+ }
132
+ });
133
+
134
+ test("the pr-env up step is the job's last and hands its report on as the job's outputs", () => {
135
+ const last = up.steps.at(-1)!;
136
+ expect(last.id).toBe("env-up");
137
+ expect(last.run!.trimEnd().split("\n").at(-1)).toBe("echo after");
138
+ expect(up.outputs).toEqual({ report_0: "${{ steps.env-up.outputs.report_0 }}" });
139
+ });
140
+
141
+ test("env-post checks out the target branch's commit, and only it can write the record, the note and the status", () => {
142
+ expect(post.needs).toEqual(["env-up"]);
143
+ expect(post.if).toContain("needs.env-up.result == 'failure'");
144
+ expect(post.steps[0]).toMatchObject({ uses: expect.stringMatching(/^actions\/checkout@/), with: { ref: "${{ github.event.pull_request.base.sha }}" } });
145
+ expect(post.permissions).toEqual({ contents: "write", "pull-requests": "write", statuses: "write" });
146
+ const last = post.steps.at(-1)!;
147
+ expect(last.env).toMatchObject({ GITHUB_TOKEN: "${{ github.token }}", HEAD_SHA: "${{ github.sha }}", UP_RESULT: "${{ needs.env-up.result }}" });
148
+ expect(last.run).toBe('chant components pr-env post --pr "$PR_NUMBER" --head "$HEAD_SHA" --forge github --output .chant/pr-env --result "$UP_RESULT"');
149
+ // The post job runs no build, no lint and no pr-env up: none of the change's code.
150
+ expect(JSON.stringify(post)).not.toMatch(/chant (build|lint)|pr-env up/);
151
+ });
152
+ });
153
+
154
+ /** Run one generated step as a forge does: its script as a file, `sh -e`, with the job's tokens in the environment. */
155
+ function runStep(step: Step, cwd: string, env: Record<string, string>): number | null {
156
+ const file = join(cwd, "..", `step-${Math.random().toString(36).slice(2)}.sh`);
157
+ writeFileSync(file, step.run!);
158
+ return spawnSync("sh", ["-e", file], { cwd, env: { ...BASE, ...TOKENS, ...env }, encoding: "utf-8" }).status;
159
+ }
160
+
161
+ describe("env-up's steps, run, see no forge token and hand the report and record on (#4295)", () => {
162
+ const { doc } = workflow();
163
+
164
+ test("each step after the checkout, and every process it starts, sees none of the token variables", () => {
165
+ const top = mkdtempSync(join(tmpdir(), "chant-generated-env-step-"));
166
+ dirs.push(top);
167
+ const work = join(top, "work");
168
+ mkdirSync(work);
169
+ const seen = join(top, "seen");
170
+ mkdirSync(seen);
171
+ const output = join(top, "github-output");
172
+ const steps = doc.jobs["env-up"]!.steps.slice(1);
173
+ steps.forEach((step, i) => {
174
+ const probe = `env > "${seen}/${i}.env"; sh -c 'env' >> "${seen}/${i}.env"`;
175
+ const report = "mkdir -p .chant/pr-env && echo '{\"stage\":\"up\"}' > .chant/pr-env/pr-env-up.json && echo '{\"kind\":\"up\"}' > .chant/pr-env/pr-env-record.json";
176
+ const lines = step.run!.split("\n");
177
+ const handover = lines.filter((l) => l.startsWith("chant_report=") || l.startsWith("chant_handover") || l.startsWith("trap "));
178
+ const body = [lines[0], ...handover, probe, ...(UP_LINE.test(step.run!) ? [report] : [])].join("\n");
179
+ expect(runStep({ run: body }, work, { GITHUB_OUTPUT: output, BASE_SHA: "b".repeat(40), PR_NUMBER: "7" }), step.name ?? lines[1]).toBe(0);
180
+ });
181
+ for (let i = 0; i < steps.length; i++) expect(readFileSync(join(seen, `${i}.env`), "utf-8")).not.toContain(SECRET);
182
+ expect(readFileSync(output, "utf-8")).toMatch(/^report_0=\S+$/m);
183
+ });
184
+
185
+ test("env-post's take-over step writes the report and the record back from that output", () => {
186
+ const top = mkdtempSync(join(tmpdir(), "chant-generated-env-post-"));
187
+ dirs.push(top);
188
+ const work = join(top, "work");
189
+ mkdirSync(join(work, ".chant", "pr-env"), { recursive: true });
190
+ writeFileSync(join(work, ".chant", "pr-env", "pr-env-up.json"), '{"stage":"up"}\n');
191
+ writeFileSync(join(work, ".chant", "pr-env", "pr-env-record.json"), '{"kind":"up"}\n');
192
+ const output = join(top, "github-output");
193
+ const upStep = doc.jobs["env-up"]!.steps.at(-1)!;
194
+ const handOn = upStep.run!.split("\n").filter((l, n) => n === 0 || l.startsWith("chant_report=") || l.startsWith("chant_handover") || l.startsWith("trap ")).join("\n");
195
+ expect(runStep({ run: handOn }, work, { GITHUB_OUTPUT: output })).toBe(0);
196
+ const value = readFileSync(output, "utf-8").trim().slice("report_0=".length);
197
+
198
+ const postWork = join(top, "post");
199
+ mkdirSync(postWork);
200
+ const take = doc.jobs["env-post"]!.steps.find((s) => s.name === "Take the environment's report from the env-up job")!;
201
+ const filled = take.run!.replace("${{ toJSON(needs.env-up.outputs.report_0) }}", JSON.stringify(value));
202
+ expect(runStep({ run: filled }, postWork, {})).toBe(0);
203
+ expect(existsSync(join(postWork, ".chant", "pr-env", "pr-env-record.json"))).toBe(true);
204
+ expect(readFileSync(join(postWork, ".chant", "pr-env", "pr-env-up.json"), "utf-8")).toBe('{"stage":"up"}\n');
205
+ });
206
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The pull-request workflow with an apply before merge (#4301, ws-106): a
3
+ * `/chant apply` comment starts the apply job from the default branch's
4
+ * workflow, which checks out the pull request's head and runs `pr-apply
5
+ * --before-merge` through `chant ci run`. A push still records.
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 { prLoopJobs, pullRequestCheckout } from "@intentius/chant/components/pr-pipeline";
12
+ import { generateGithubPipeline } from "./generate-pipeline";
13
+
14
+ const ESTATE: DriverComponent[] = [
15
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
16
+ ];
17
+
18
+ interface Step { name?: string; run?: string; env?: Record<string, string> }
19
+ interface Job { if: string; permissions: Record<string, string>; steps: Step[] }
20
+
21
+ describe("the GitHub workflow with --apply-before-merge", () => {
22
+ const doc = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyBeforeMerge: true } }).yaml) as { on: Record<string, unknown>; jobs: Record<string, Job> };
23
+
24
+ test("listens to new comments beside pull requests and pushes, and keeps the record job on a push", () => {
25
+ expect(doc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] }, issue_comment: { types: ["created"] } });
26
+ expect(Object.keys(doc.jobs)).toEqual(["plan", "plan-note", "record", "apply"]);
27
+ expect(doc.jobs.record!.if).toBe("github.event_name == 'push'");
28
+ });
29
+
30
+ test("the apply runs on a /chant apply comment on a pull request from someone who can write", () => {
31
+ const cond = doc.jobs.apply!.if;
32
+ expect(cond).toContain("github.event_name == 'issue_comment'");
33
+ expect(cond).toContain("github.event.issue.pull_request");
34
+ expect(cond).toContain("startsWith(github.event.comment.body, '/chant apply')");
35
+ expect(cond).toContain("github.event.comment.author_association");
36
+ });
37
+
38
+ test("it checks out the pull request's head in its run step and applies before merge with the target branch's commit as the base", () => {
39
+ const step = doc.jobs.apply!.steps.find((s) => s.run?.includes("chant ci run apply"))!;
40
+ const lines = step.run!.trimEnd().split("\n");
41
+ expect(lines[0]).toBe(pullRequestCheckout("github"));
42
+ expect(lines.at(-1)).toBe('chant ci run apply --in-place --base "$BASE_SHA" --pr "$PR_NUMBER" --apply-before-merge --env prod --gate pr-apply --forge github');
43
+ expect(step.env).toMatchObject({ BASE_SHA: "${{ github.sha }}", PR_NUMBER: "${{ github.event.issue.number }}" });
44
+ });
45
+
46
+ test("the command chant ci run rebuilds asks pr-apply for the apply before merge", () => {
47
+ const apply = prLoopJobs("github", "prod", { applyBeforeMerge: true }).find((j) => j.jobName === "apply")!;
48
+ expect(apply.command).toBe('chant components pr-apply --before-merge --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge github --resume .chant/pr-resume/pr-apply.json');
49
+ });
50
+
51
+ test("it takes no apply on merge, no wave gate and no build", () => {
52
+ expect(() => prLoopJobs("github", "prod", { applyBeforeMerge: true, applyOnMerge: true })).toThrow(/--apply-on-merge/);
53
+ expect(() => prLoopJobs("github", "prod", { applyBeforeMerge: true, waveGate: "w" })).toThrow(/--wave-gate/);
54
+ expect(() => prLoopJobs("github", "prod", { applyBeforeMerge: true, build: true })).toThrow(/--pr-build/);
55
+ });
56
+
57
+ test("the pull request checkout refuses a number that is not one", () => {
58
+ expect(pullRequestCheckout("github")).toMatch(/^echo "\$PR_NUMBER" \| grep -Eq '\^\[0-9\]\+\$' && git fetch --quiet origin "\+refs\/pull\/\$PR_NUMBER\/head:/);
59
+ expect(pullRequestCheckout("gitlab")).toContain("refs/merge-requests/$PR_NUMBER/head");
60
+ });
61
+
62
+ test("on Forgejo the comment may also say so with is_pull, and there is no author association", () => {
63
+ const forgejo = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyBeforeMerge: true, forge: "forgejo" } }).yaml) as { jobs: Record<string, Job> };
64
+ expect(forgejo.jobs.apply!.if).toContain("(github.event.issue.pull_request || github.event.is_pull)");
65
+ expect(forgejo.jobs.apply!.if).not.toContain("author_association");
66
+ });
67
+ });