@intentius/chant-lexicon-github 0.124.39 → 0.124.41

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.
@@ -23,6 +23,15 @@ export declare function downloadArtifactStep(name: string, artifact: string, pat
23
23
  * replaces it. Nothing to keep is not an error.
24
24
  */
25
25
  export declare function keepRecordStep(name: string, artifact: string, path: string): Record<string, unknown>;
26
+ /**
27
+ * The `if:` of a wave job after the first (#4291): it runs when the wave
28
+ * before it failed, so a failed member blocks only the members that depend
29
+ * on it, and the next wave's job blocks those and runs the rest. It does not
30
+ * run when the run was cancelled, or when the job before it was skipped
31
+ * (its own condition was false). `condition` is the job's own condition,
32
+ * which still has to hold.
33
+ */
34
+ export declare function afterEarlierWave(previous: string, condition?: string): string;
26
35
  /** A `concurrency:` mapping: one run at a time per group. */
27
36
  export declare function concurrency(group: string, cancelInProgress?: boolean): Record<string, unknown>;
28
37
  /** The job token, as a step's `GITHUB_TOKEN`; `gh` reads `GH_TOKEN` too, for a step that writes. */
@@ -1 +1 @@
1
- {"version":3,"file":"ci-jobs.d.ts","sourceRoot":"","sources":["../../src/components/ci-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAG1D,qGAAqG;AACrG,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,iBAAiB,iCAAiC,CAAC;AAEhE,iIAAiI;AACjI,wBAAgB,YAAY,CAAC,WAAW,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,yEAAyE;AACzE,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAEjF;AAED,oDAAoD;AACpD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE1G;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOpG;AAED,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,gBAAgB,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE5F;AAED,oGAAoG;AACpG,wBAAgB,WAAW,CAAC,EAAE,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAE9D;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAYrE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,GAAG,MAAM,CAG9E;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,qGAAqG;IACrG,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8BAA8B;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iCAAiC;IACjC,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,kCAAkC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,kCAAkC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,kFAAkF;AAClF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAW/D"}
1
+ {"version":3,"file":"ci-jobs.d.ts","sourceRoot":"","sources":["../../src/components/ci-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAG1D,qGAAqG;AACrG,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,iBAAiB,iCAAiC,CAAC;AAEhE,iIAAiI;AACjI,wBAAgB,YAAY,CAAC,WAAW,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,yEAAyE;AACzE,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAEjF;AAED,oDAAoD;AACpD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE1G;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOpG;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAG7E;AAED,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,gBAAgB,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE5F;AAED,oGAAoG;AACpG,wBAAgB,WAAW,CAAC,EAAE,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAE9D;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAYrE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,GAAG,MAAM,CAG9E;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,qGAAqG;IACrG,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8BAA8B;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iCAAiC;IACjC,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,kCAAkC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,kCAAkC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,kFAAkF;AAClF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAW/D"}
@@ -38,6 +38,18 @@ export function keepRecordStep(name, artifact, path) {
38
38
  with: { name: artifact, path, "if-no-files-found": "ignore", "include-hidden-files": true, overwrite: true },
39
39
  };
40
40
  }
41
+ /**
42
+ * The `if:` of a wave job after the first (#4291): it runs when the wave
43
+ * before it failed, so a failed member blocks only the members that depend
44
+ * on it, and the next wave's job blocks those and runs the rest. It does not
45
+ * run when the run was cancelled, or when the job before it was skipped
46
+ * (its own condition was false). `condition` is the job's own condition,
47
+ * which still has to hold.
48
+ */
49
+ export function afterEarlierWave(previous, condition) {
50
+ const runs = `!cancelled() && needs.${previous}.result != 'skipped'`;
51
+ return condition ? `${runs} && (${condition})` : runs;
52
+ }
41
53
  /** A `concurrency:` mapping: one run at a time per group. */
42
54
  export function concurrency(group, cancelInProgress = false) {
43
55
  return { group, "cancel-in-progress": cancelInProgress };
@@ -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;AAmBjG,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;AA+ND;;;;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;AA2BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,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;AAsaD;;;;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,15 @@
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_RUNNER } from "@intentius/chant/ci-jobs";
62
- import { GREEN_TAG_PATTERN, GREEN_TICK_WORKFLOW, PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopSetup, prLoopWaveJobs, prLoopWithWaves, } from "@intentius/chant/components/pr-pipeline";
61
+ import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
62
+ import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
63
+ import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
64
+ import { GREEN_TAG_PATTERN, GREEN_TICK_WORKFLOW, PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, PR_ENV_REPORT_DIR, PR_ENV_SWEEP_CRON, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
63
65
  import { memberRepoPath } from "@intentius/chant/lexicon";
64
- import { checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs.js";
66
+ import { afterEarlierWave, checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs.js";
67
+ import { actionRef } from "../action-pins.js";
68
+ /** The plan job's last step, whose outputs carry its report to the plan-note job (#4295). */
69
+ const PLAN_STEP_ID = "plan";
65
70
  /** The output a gated apply wave hands the green commit it applies through, and the step that sets it (#4221). */
66
71
  const WAVE_GREEN_OUTPUT = "green-sha";
67
72
  const WAVE_STEP_ID = "apply";
@@ -89,6 +94,8 @@ function gatedWaveGithubDoc(components, env, image, beforeScript, extraScript, o
89
94
  steps.push(...runSteps(prLoopSetup(image)), ...runSteps(beforeScript), { run: job.command.join(" ") }, ...runSteps(extraScript));
90
95
  steps.push(keepRecordStep(`Upload the record for wave ${job.wave}`, waveRecordArtifact(job.wave), GATED_WAVE_RECORD));
91
96
  jobsDoc[job.jobName] = {
97
+ // #4291: a wave runs after one that failed, and blocks only what depends on the failure.
98
+ ...(job.needs.length > 0 ? { if: afterEarlierWave(job.needs[0]) } : {}),
92
99
  "runs-on": GITHUB_RUNNER,
93
100
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
94
101
  container: image,
@@ -133,6 +140,26 @@ function gatedWaveGithubDoc(components, env, image, beforeScript, extraScript, o
133
140
  * for the pushed commit, so wave k reads what wave k-1 left, and a re-run of
134
141
  * a wave that stopped at its gate reads what it left itself. Each keeps its
135
142
  * report as `chant-pr-apply-wave-<k>`.
143
+ *
144
+ * The plan job holds no forge token while the pull request's code runs
145
+ * (#4295, `@intentius/chant/ci-handover`). It checks the change out with a
146
+ * script that gives the job's token to its fetches alone, starts every later
147
+ * step again without the token variables, and ends with the plan step, which
148
+ * hands its report on as the job's outputs. The `plan-note` job then checks
149
+ * out the target branch's commit, takes the report back, keeps it as the
150
+ * `chant-pr-plan` artifact, and posts the note and `chant/plan` with
151
+ * `chant components pr-post`. Only the note job may write to the pull
152
+ * request and its statuses.
153
+ *
154
+ * With `identity` (#4296, `@intentius/chant/ci-identity`) the plan, record
155
+ * and apply jobs get `id-token: write`, and each starts with a step that
156
+ * trades the job's OIDC token for its cloud identity before anything is
157
+ * checked out: the plan and record jobs for the plan identity, the apply
158
+ * jobs for the apply identity. The trade leaves the credentials in
159
+ * `GITHUB_ENV`. Every later step of the plan job starts without the
160
+ * variables a token is requested with, so the pull request's code holds no
161
+ * OIDC token and cannot request one, and the plan job names no apply
162
+ * identity.
136
163
  */
137
164
  function prLoopGithubDoc(components, env, image, beforeScript, extraScript, options) {
138
165
  const loop = options.prLoop;
@@ -158,8 +185,18 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
158
185
  uses: "actions/cache/save@v4",
159
186
  with: { path: recordPath, key: recordKey(wave) },
160
187
  });
188
+ // #4296: the step that trades the job's OIDC token for its cloud identity, before the checkout.
189
+ const identity = loop.identity;
190
+ const tradeStep = (spec, which) => ({
191
+ name: `Trade the job's OIDC token for the ${which} identity`,
192
+ ...(rooted ? {} : { "working-directory": "." }),
193
+ env: { [TRADE_SPEC_ENV]: JSON.stringify(spec) },
194
+ run: tradeLine("$GITHUB_ENV", "github-env"),
195
+ });
196
+ const withIdToken = (permissions) => (identity ? { ...permissions, "id-token": "write" } : permissions);
161
197
  const reportName = (job) => `chant-pr-${job.wave ? prApplyWaveJobName(job.wave) : job.jobName}`;
162
198
  const steps = (job, name) => [
199
+ ...(identity ? [tradeStep(job.jobName === "record" ? identity.plan : identity.apply, job.jobName === "record" ? "plan" : "apply")] : []),
163
200
  checkoutStep(true),
164
201
  ...runSteps(prLoopSetup(image)),
165
202
  ...runSteps(beforeScript),
@@ -190,12 +227,13 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
190
227
  const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : "github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'";
191
228
  const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
192
229
  const applyJob = (job, needs, name) => ({
193
- if: job.jobName === "record" ? onPush : onGreen,
230
+ // #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
231
+ if: job.jobName === "record" ? onPush : needs.length > 0 && job.wave ? afterEarlierWave(needs[0], onGreen) : onGreen,
194
232
  "runs-on": GITHUB_RUNNER,
195
233
  ...(needs.length > 0 ? { needs } : {}),
196
234
  container: image,
197
235
  concurrency: concurrency(prApplyGroup(env, member?.name)),
198
- permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
236
+ permissions: withIdToken({ contents: "write", "pull-requests": "write", statuses: "write" }),
199
237
  ...(job.wave ? { outputs: { [WAVE_GREEN_OUTPUT]: `\${{ steps.${WAVE_STEP_ID}.outputs.${WAVE_GREEN_OUTPUT} }}` } } : {}),
200
238
  steps: steps(job, name),
201
239
  });
@@ -210,31 +248,84 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
210
248
  ? [["apply", applyJob(apply, [], "Apply the newest green commit under the merged changes' approvals")]]
211
249
  : [];
212
250
  const applyJobs = [recordJob, ...greenJobs];
251
+ // #4295: the plan job runs the pull request's code and holds no forge token while it does.
252
+ const forgeName = loop.forge ?? "github";
253
+ // #4296: once the plan job has traded its OIDC token, its later steps start without the variables a token is requested with.
254
+ const drop = dropForgeTokens("sh", identity ? OIDC_REQUEST_VARS : []);
255
+ const headStep = (line, name) => ({ ...(name ? { name } : {}), run: `${drop}\n${line}` });
256
+ const planSteps = [
257
+ // #4296: the trade runs first, before the change is checked out, with the plan identity alone.
258
+ ...(identity ? [tradeStep(identity.plan, "plan")] : []),
259
+ // A script, not actions/checkout, whose post step runs at the job's end with the token as its input.
260
+ {
261
+ name: "Check out the change",
262
+ ...(rooted ? {} : { "working-directory": "." }),
263
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
264
+ run: checkoutScript(true, true),
265
+ },
266
+ ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
267
+ ...(plan.build ? [headStep(plan.build[0], "Build the templates"), headStep(plan.build[1], "Lint the project")] : []),
268
+ // The job's last step: a step after it would start beside whatever the change's code left running.
269
+ {
270
+ name: "Plan the members this pull request reaches",
271
+ id: PLAN_STEP_ID,
272
+ env: plan.run.env,
273
+ run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), ...(plan.checkout ?? []), ...(plan.setup ?? []), plan.run.command, ...extraScript].join("\n"),
274
+ },
275
+ ];
276
+ const post = prLoopPostJob(forgeName, env, member?.name);
277
+ const noteSteps = [
278
+ // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
279
+ { name: "Check out the target branch's commit", uses: actionRef("actions/checkout"), with: { ref: "${{ github.event.pull_request.base.sha }}" } },
280
+ ...runSteps(beforeScript),
281
+ { name: "Take the plan report from the plan job", run: takeOverScript("plan", PR_LOOP_REPORT_DIR, forgeName) },
282
+ { 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 } },
283
+ { name: "Post the plan note and chant/plan", if: "always()", env: post.env, run: post.command },
284
+ ];
285
+ // #4298: with a pull request environment the pull_request trigger fires on close too, and the plan skips that event.
286
+ const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
213
287
  const jobsDoc = {
214
288
  plan: {
215
- if: "github.event_name == 'pull_request'",
289
+ if: prEnv ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
290
+ "runs-on": GITHUB_RUNNER,
291
+ container: image,
292
+ permissions: withIdToken({ contents: "read" }),
293
+ outputs: handoverOutputs(PLAN_STEP_ID, forgeName),
294
+ steps: planSteps,
295
+ },
296
+ [PR_PLAN_NOTE_JOB]: {
297
+ needs: ["plan"],
298
+ if: "always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')",
216
299
  "runs-on": GITHUB_RUNNER,
217
300
  container: image,
218
- permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
219
- steps: steps(plan, "Plan the members this pull request reaches"),
301
+ // #4289: `actions: read` lets pr-post ask when each scheduled watch or reconcile Op last ran.
302
+ permissions: { actions: "read", contents: "read", "pull-requests": "write", statuses: "write" },
303
+ steps: noteSteps,
220
304
  },
221
305
  ...Object.fromEntries(applyJobs),
306
+ ...(prEnv ? prEnv.jobs : {}),
222
307
  };
223
308
  const applyNames = applyJobs.map(([name]) => name);
309
+ const on = !loop.applyOnMerge
310
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch] } }
311
+ : forgejo
312
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch], tags: [GREEN_TAG_PATTERN] } }
313
+ : { pull_request: { branches: [branch] }, push: { branches: [branch] }, workflow_run: { workflows: [GREEN_TICK_WORKFLOW], types: ["completed"] }, workflow_dispatch: {} };
314
+ if (prEnv) {
315
+ on.pull_request = { branches: [branch], types: ["opened", "synchronize", "reopened", "closed"] };
316
+ on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
317
+ }
224
318
  return {
225
319
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
226
320
  environment: env,
227
- on: !loop.applyOnMerge
228
- ? { pull_request: { branches: [branch] }, push: { branches: [branch] } }
229
- : forgejo
230
- ? { pull_request: { branches: [branch] }, push: { branches: [branch], tags: [GREEN_TAG_PATTERN] } }
231
- : { pull_request: { branches: [branch] }, push: { branches: [branch] }, workflow_run: { workflows: [GREEN_TICK_WORKFLOW], types: ["completed"] }, workflow_dispatch: {} },
321
+ on,
232
322
  ...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
233
323
  env: { ...options.variables, CHANT_ENV: env },
234
324
  jobsDoc,
235
- stages: ["plan", ...applyNames],
325
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...applyNames, ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
236
326
  jobs: [
237
327
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
328
+ { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
238
329
  ...applyNames.map((name, i) => ({
239
330
  jobName: name,
240
331
  component: name === "record" ? "merge record" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
@@ -242,9 +333,109 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
242
333
  // The record runs on a push and the apply on a green commit, so only the waves need each other.
243
334
  needs: i > 1 ? [applyNames[i - 1]] : [],
244
335
  })),
336
+ ...(prEnv ? prEnv.listed : []),
337
+ ],
338
+ };
339
+ }
340
+ /**
341
+ * The jobs of a pull request's environment (#4298). `env-up` runs on an
342
+ * opened, updated or reopened pull request and runs the change's code, so it
343
+ * follows the plan job's handover (#4295): a checkout script that alone sees
344
+ * the job's token, every later step started without the token variables,
345
+ * and the `pr-env up` step last, handing its report and record on as the
346
+ * job's outputs. `env-post` checks out the target branch's commit, takes
347
+ * them back, keeps the record on chant/lifecycle and posts the note.
348
+ * `env-down` runs when the pull request closes and `env-sweep` on the
349
+ * schedule, both from the base branch's code: a teardown reads the markers
350
+ * off the live resources and builds nothing of the change. `env-up` and
351
+ * `env-down` run one at a time per pull request, and a close waits for an
352
+ * apply still running rather than cancelling it.
353
+ */
354
+ function prEnvGithubJobs(forge, image, beforeScript, extraScript, loop, member) {
355
+ const [up, post, down, sweep] = prEnvJobs(forge, loop, member?.name);
356
+ const rooted = !member || member.dir === "." || member.dir === "";
357
+ const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
358
+ const prNumber = "${{ github.event.pull_request.number }}";
359
+ const group = `chant-pr-env-${member ? `${member.name}-` : ""}${prNumber}`;
360
+ const writes = { contents: "write", "pull-requests": "write", statuses: "write" };
361
+ const keep = (name) => ({ 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 } });
362
+ const opened = "github.event_name == 'pull_request' && github.event.action != 'closed'";
363
+ // #4295: env-up runs the change's code and holds no forge token while it does.
364
+ const drop = dropForgeTokens("sh");
365
+ const headStep = (line, name, env) => ({ ...(name ? { name } : {}), ...(env ? { env } : {}), run: `${drop}\n${line}` });
366
+ const upJob = {
367
+ if: opened,
368
+ "runs-on": GITHUB_RUNNER,
369
+ container: image,
370
+ concurrency: concurrency(group),
371
+ permissions: { contents: "read" },
372
+ outputs: handoverOutputs(PR_ENV_UP_STEP_ID, forge),
373
+ steps: [
374
+ {
375
+ name: "Check out the change",
376
+ ...(rooted ? {} : { "working-directory": "." }),
377
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
378
+ run: checkoutScript(true, true),
379
+ },
380
+ ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
381
+ ...(up.build ? [headStep(up.build[0], "Build the templates", { CHANT_ENV: up.env.CHANT_ENV }), headStep(up.build[1], "Lint the project")] : []),
382
+ // The job's last step: a step after it would start beside whatever the change's code left running.
383
+ {
384
+ name: "Apply this pull request's environment once its plan is approved",
385
+ id: PR_ENV_UP_STEP_ID,
386
+ env: up.env,
387
+ run: [drop, ...handoverTrap(PR_ENV_REPORT_DIR, forge), up.command, ...extraScript].join("\n"),
388
+ },
389
+ ],
390
+ };
391
+ const postJob = {
392
+ needs: ["env-up"],
393
+ if: `always() && ${opened} && (needs.env-up.result == 'success' || needs.env-up.result == 'failure')`,
394
+ "runs-on": GITHUB_RUNNER,
395
+ container: image,
396
+ concurrency: concurrency(`chant-pr-env-post-${member ? `${member.name}-` : ""}${prNumber}`),
397
+ permissions: writes,
398
+ steps: [
399
+ // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
400
+ { name: "Check out the target branch's commit", uses: actionRef("actions/checkout"), with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
401
+ ...runSteps(beforeScript),
402
+ { name: "Take the environment's report from the env-up job", run: takeOverScript("env-up", PR_ENV_REPORT_DIR, forge) },
403
+ keep("env-up"),
404
+ { name: "Keep the environment's record and post its note", if: "always()", env: post.env, run: post.command },
405
+ ],
406
+ };
407
+ const baseJob = (j, condition, ref, name, groupName) => ({
408
+ if: condition,
409
+ "runs-on": GITHUB_RUNNER,
410
+ container: image,
411
+ concurrency: concurrency(groupName),
412
+ permissions: writes,
413
+ steps: [
414
+ { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
415
+ ...runSteps(prLoopSetup(image)),
416
+ ...runSteps(beforeScript),
417
+ { name, env: j.env, run: j.command },
418
+ ...runSteps(extraScript),
419
+ keep(j.jobName),
420
+ ],
421
+ });
422
+ return {
423
+ jobs: {
424
+ "env-up": upJob,
425
+ [PR_ENV_POST_JOB]: postJob,
426
+ "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),
427
+ "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}` : ""}`),
428
+ },
429
+ listed: [
430
+ { jobName: "env-up", component: "pull request environment", stage: "env-up", needs: [] },
431
+ { jobName: PR_ENV_POST_JOB, component: "pull request environment note", stage: PR_ENV_POST_JOB, needs: ["env-up"] },
432
+ { jobName: "env-down", component: "pull request environment teardown", stage: "env-down", needs: [] },
433
+ { jobName: "env-sweep", component: "pull request environment sweep", stage: "env-sweep", needs: [] },
245
434
  ],
246
435
  };
247
436
  }
437
+ /** The id of the `pr-env up` step, whose outputs carry the report (#4295). */
438
+ const PR_ENV_UP_STEP_ID = "env-up";
248
439
  /** 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. */
249
440
  function outputsFile(name) {
250
441
  return `${name}.outputs.json`;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "b550ae4f8383d258d7db9b2b59508e3dbb7472343af6dbb08b13fab083f180c3",
4
+ "manifest.json": "91be5cd758d335f713fe5eee9fdde779edfb2baf9ed1d38bc248487dfeb4301d",
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": "4d26c5b28576ae8749b7e02424680e049ffebb75b645950f54a8c60ce1b776a9"
81
+ "composite": "5c0703f8385fcd34f6d37731e92126fa3390127f40f3f829cb1eb2d33c011bee"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.39",
3
+ "version": "0.124.41",
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.39",
3
+ "version": "0.124.41",
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.39"
64
+ "@intentius/chant": "^0.124.41"
65
65
  }
66
66
  }
@@ -24,24 +24,82 @@ jobs:
24
24
  container: node:22
25
25
  permissions:
26
26
  contents: read
27
- pull-requests: write
28
- statuses: write
27
+ outputs:
28
+ report_0: '${{ steps.plan.outputs.report_0 }}'
29
29
  steps:
30
- - uses: actions/checkout@v7
31
- with:
32
- fetch-depth: 0
33
- - run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
30
+ - name: Check out the change
31
+ env:
32
+ CHANT_CHECKOUT_TOKEN: '${{ github.token }}'
33
+ run: |
34
+ set -eu
35
+ git config --global --add safe.directory "$PWD" 2>/dev/null || true
36
+ [ -d .git ] || git init -q .
37
+ git remote get-url origin >/dev/null 2>&1 || git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
38
+ if [ -n "${CHANT_CHECKOUT_TOKEN:-}" ]; then
39
+ chant_auth="$(printf 'x-access-token:%s' "$CHANT_CHECKOUT_TOKEN" | base64 | tr -d '\n')"
40
+ export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraheader GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $chant_auth"
41
+ unset chant_auth
42
+ fi
43
+ ref="$GITHUB_REF"
44
+ want="$GITHUB_SHA"
45
+ case "$ref" in
46
+ refs/heads/*) target="refs/remotes/origin/${ref#refs/heads/}" ;;
47
+ refs/tags/*) target="$ref" ;;
48
+ refs/*) target="refs/remotes/${ref#refs/}" ;;
49
+ *) target="refs/remotes/origin/chant-head" ;;
50
+ esac
51
+ if git rev-parse -q --verify HEAD >/dev/null; then git clean -ffdxq; fi
52
+ git -c protocol.version=2 fetch -q --prune --no-recurse-submodules origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' "+$want:$target"
53
+ git -c protocol.version=2 fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true
54
+ case "$ref" in
55
+ refs/heads/*) git checkout -q --force -B "${ref#refs/heads/}" "$target" ;;
56
+ *) git checkout -q --force --detach "$target" ;;
57
+ esac
58
+ - run: |
59
+ if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
60
+ command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
34
61
  - name: Build the templates
35
- run: chant build --env prod
62
+ run: |
63
+ if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
64
+ chant build --env prod
36
65
  - name: Lint the project
37
- run: chant lint
66
+ run: |
67
+ if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
68
+ chant lint
38
69
  - name: Plan the members this pull request reaches
70
+ id: plan
39
71
  env:
40
72
  BASE_SHA: '${{ github.event.pull_request.base.sha }}'
41
73
  PR_NUMBER: '${{ github.event.pull_request.number }}'
42
- GITHUB_TOKEN: '${{ github.token }}'
43
74
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
44
- run: chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github
75
+ run: |
76
+ if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
77
+ chant_report="$PWD/.chant/pr"
78
+ chant_handover() { node -e 'const fs=require("fs"),p=require("path"),z=require("zlib");const [dir,out]=process.argv.slice(1);const CHUNK=450000,PARTS=1,NOTE=["pr-plan.json","pr-note.md"];const all={};const walk=(d,r)=>{let n=[];try{n=fs.readdirSync(d).sort()}catch{return}for(const x of n){const f=p.join(d,x),k=r?r+"/"+x:x;let s;try{s=fs.lstatSync(f)}catch{continue}if(s.isDirectory())walk(f,k);else if(s.isFile())all[k]=fs.readFileSync(f).toString("base64")}};walk(dir,"");const pack=(o)=>z.gzipSync(JSON.stringify(o)).toString("base64");let b=pack(all);if(b.length>CHUNK*PARTS){b=pack(Object.fromEntries(Object.entries(all).filter(([k])=>NOTE.includes(k))));if(b.length>CHUNK*PARTS){b=pack({});console.error("chant: the plan report is too large to hand to the note job, even cut to the report and the note; it hands on none of it")}else console.error("chant: the plan report is too large to hand to the note job whole; it hands on pr-plan.json and pr-note.md")}let o="";for(let i=0;i*CHUNK<b.length;i++)o+="report_"+i+"="+b.slice(i*CHUNK,(i+1)*CHUNK)+"\n";fs.appendFileSync(out,o);' "$chant_report" "${GITHUB_OUTPUT:-/dev/null}" || echo "chant: the plan report could not be handed to the note job" >&2; }
79
+ trap chant_handover EXIT
80
+ chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github
81
+ plan-note:
82
+ needs:
83
+ - plan
84
+ if: always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')
85
+ runs-on: ubuntu-latest
86
+ container: node:22
87
+ permissions:
88
+ actions: read
89
+ contents: read
90
+ pull-requests: write
91
+ statuses: write
92
+ steps:
93
+ - name: Check out the target branch's commit
94
+ uses: actions/checkout@v7
95
+ with:
96
+ ref: '${{ github.event.pull_request.base.sha }}'
97
+ - name: Take the plan report from the plan job
98
+ run: |
99
+ set -u
100
+ node -e 'const fs=require("fs"),p=require("path"),z=require("zlib");const dir=process.argv[1];let b="";for(const l of fs.readFileSync(0,"utf8").split("\n")){if(!l.trim())continue;let s="";try{s=JSON.parse(l)}catch{}if(typeof s==="string")b+=s}if(!b){console.log("chant: the plan job handed on no report");process.exit(0)}let files;try{files=JSON.parse(z.gunzipSync(Buffer.from(b,"base64"),{maxOutputLength:268435456}).toString("utf8"))}catch(e){console.log("chant: the report the plan job handed on does not read: "+e.message);process.exit(0)}if(!files||typeof files!=="object"||Array.isArray(files)){console.log("chant: the report the plan job handed on is not a set of files");process.exit(0)}let n=0;for(const [k,v] of Object.entries(files)){const parts=k.split("/");if(typeof v!=="string"||k.length>1024||parts.some((x)=>!x||x==="."||x===".."||/[\\\x00-\x1f]/.test(x))){console.log("chant: left out "+JSON.stringify(k));continue}try{const f=p.join(dir,...parts);fs.mkdirSync(p.dirname(f),{recursive:true});fs.writeFileSync(f,Buffer.from(v,"base64"));n++}catch(e){console.log("chant: could not write "+JSON.stringify(k)+": "+e.message)}}console.log("chant: took "+n+" report files from the plan job")' .chant/pr <<'END_OF_REPORT'
101
+ ${{ toJSON(needs.plan.outputs.report_0) }}
102
+ END_OF_REPORT
45
103
  - name: Keep the plan report
46
104
  if: always()
47
105
  uses: actions/upload-artifact@v4
@@ -50,6 +108,14 @@ jobs:
50
108
  path: .chant/pr
51
109
  if-no-files-found: ignore
52
110
  include-hidden-files: true
111
+ - name: Post the plan note and chant/plan
112
+ if: always()
113
+ env:
114
+ PR_NUMBER: '${{ github.event.pull_request.number }}'
115
+ HEAD_SHA: '${{ github.sha }}'
116
+ PLAN_RESULT: '${{ needs.plan.result }}'
117
+ GITHUB_TOKEN: '${{ github.token }}'
118
+ run: chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge github --result "$PLAN_RESULT"
53
119
  record:
54
120
  if: github.event_name == 'push'
55
121
  runs-on: ubuntu-latest
@@ -18,20 +18,74 @@ jobs:
18
18
  container: node:22
19
19
  permissions:
20
20
  contents: read
21
- pull-requests: write
22
- statuses: write
21
+ outputs:
22
+ report_0: '${{ steps.plan.outputs.report_0 }}'
23
23
  steps:
24
- - uses: actions/checkout@v7
25
- with:
26
- fetch-depth: 0
27
- - run: command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
24
+ - name: Check out the change
25
+ env:
26
+ CHANT_CHECKOUT_TOKEN: '${{ github.token }}'
27
+ run: |
28
+ set -eu
29
+ git config --global --add safe.directory "$PWD" 2>/dev/null || true
30
+ [ -d .git ] || git init -q .
31
+ git remote get-url origin >/dev/null 2>&1 || git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
32
+ if [ -n "${CHANT_CHECKOUT_TOKEN:-}" ]; then
33
+ chant_auth="$(printf 'x-access-token:%s' "$CHANT_CHECKOUT_TOKEN" | base64 | tr -d '\n')"
34
+ export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraheader GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $chant_auth"
35
+ unset chant_auth
36
+ fi
37
+ ref="$GITHUB_REF"
38
+ want="$GITHUB_SHA"
39
+ case "$ref" in
40
+ refs/heads/*) target="refs/remotes/origin/${ref#refs/heads/}" ;;
41
+ refs/tags/*) target="$ref" ;;
42
+ refs/*) target="refs/remotes/${ref#refs/}" ;;
43
+ *) target="refs/remotes/origin/chant-head" ;;
44
+ esac
45
+ if git rev-parse -q --verify HEAD >/dev/null; then git clean -ffdxq; fi
46
+ git -c protocol.version=2 fetch -q --prune --no-recurse-submodules origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' "+$want:$target"
47
+ git -c protocol.version=2 fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true
48
+ case "$ref" in
49
+ refs/heads/*) git checkout -q --force -B "${ref#refs/heads/}" "$target" ;;
50
+ *) git checkout -q --force --detach "$target" ;;
51
+ esac
52
+ - run: |
53
+ if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
54
+ command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
28
55
  - name: Plan the members this pull request reaches
56
+ id: plan
29
57
  env:
30
58
  BASE_SHA: '${{ github.event.pull_request.base.sha }}'
31
59
  PR_NUMBER: '${{ github.event.pull_request.number }}'
32
- GITHUB_TOKEN: '${{ github.token }}'
33
60
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
34
- run: chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github
61
+ run: |
62
+ if [ -z "${CHANT_NO_FORGE_TOKEN:-}" ] && [ "${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "${0##*/}" = sh ] || [ "${0##*/}" = bash ] || [ "${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v sh >/dev/null 2>&1; then exec env -u CHANT_FORGE_TOKEN -u CHANT_GITHUB_TOKEN -u CHANT_FORGEJO_TOKEN -u CHANT_GITLAB_TOKEN -u CHANT_CHECKOUT_TOKEN -u GITHUB_TOKEN -u GH_TOKEN -u FORGEJO_TOKEN -u GITEA_TOKEN -u GITLAB_TOKEN -u CI_JOB_TOKEN -u ACTIONS_RUNTIME_TOKEN CHANT_NO_FORGE_TOKEN=1 sh -e "$0"; fi; unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
63
+ chant_report="$PWD/.chant/pr"
64
+ chant_handover() { node -e 'const fs=require("fs"),p=require("path"),z=require("zlib");const [dir,out]=process.argv.slice(1);const CHUNK=450000,PARTS=1,NOTE=["pr-plan.json","pr-note.md"];const all={};const walk=(d,r)=>{let n=[];try{n=fs.readdirSync(d).sort()}catch{return}for(const x of n){const f=p.join(d,x),k=r?r+"/"+x:x;let s;try{s=fs.lstatSync(f)}catch{continue}if(s.isDirectory())walk(f,k);else if(s.isFile())all[k]=fs.readFileSync(f).toString("base64")}};walk(dir,"");const pack=(o)=>z.gzipSync(JSON.stringify(o)).toString("base64");let b=pack(all);if(b.length>CHUNK*PARTS){b=pack(Object.fromEntries(Object.entries(all).filter(([k])=>NOTE.includes(k))));if(b.length>CHUNK*PARTS){b=pack({});console.error("chant: the plan report is too large to hand to the note job, even cut to the report and the note; it hands on none of it")}else console.error("chant: the plan report is too large to hand to the note job whole; it hands on pr-plan.json and pr-note.md")}let o="";for(let i=0;i*CHUNK<b.length;i++)o+="report_"+i+"="+b.slice(i*CHUNK,(i+1)*CHUNK)+"\n";fs.appendFileSync(out,o);' "$chant_report" "${GITHUB_OUTPUT:-/dev/null}" || echo "chant: the plan report could not be handed to the note job" >&2; }
65
+ trap chant_handover EXIT
66
+ chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github
67
+ plan-note:
68
+ needs:
69
+ - plan
70
+ if: always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')
71
+ runs-on: ubuntu-latest
72
+ container: node:22
73
+ permissions:
74
+ actions: read
75
+ contents: read
76
+ pull-requests: write
77
+ statuses: write
78
+ steps:
79
+ - name: Check out the target branch's commit
80
+ uses: actions/checkout@v7
81
+ with:
82
+ ref: '${{ github.event.pull_request.base.sha }}'
83
+ - name: Take the plan report from the plan job
84
+ run: |
85
+ set -u
86
+ node -e 'const fs=require("fs"),p=require("path"),z=require("zlib");const dir=process.argv[1];let b="";for(const l of fs.readFileSync(0,"utf8").split("\n")){if(!l.trim())continue;let s="";try{s=JSON.parse(l)}catch{}if(typeof s==="string")b+=s}if(!b){console.log("chant: the plan job handed on no report");process.exit(0)}let files;try{files=JSON.parse(z.gunzipSync(Buffer.from(b,"base64"),{maxOutputLength:268435456}).toString("utf8"))}catch(e){console.log("chant: the report the plan job handed on does not read: "+e.message);process.exit(0)}if(!files||typeof files!=="object"||Array.isArray(files)){console.log("chant: the report the plan job handed on is not a set of files");process.exit(0)}let n=0;for(const [k,v] of Object.entries(files)){const parts=k.split("/");if(typeof v!=="string"||k.length>1024||parts.some((x)=>!x||x==="."||x===".."||/[\\\x00-\x1f]/.test(x))){console.log("chant: left out "+JSON.stringify(k));continue}try{const f=p.join(dir,...parts);fs.mkdirSync(p.dirname(f),{recursive:true});fs.writeFileSync(f,Buffer.from(v,"base64"));n++}catch(e){console.log("chant: could not write "+JSON.stringify(k)+": "+e.message)}}console.log("chant: took "+n+" report files from the plan job")' .chant/pr <<'END_OF_REPORT'
87
+ ${{ toJSON(needs.plan.outputs.report_0) }}
88
+ END_OF_REPORT
35
89
  - name: Keep the plan report
36
90
  if: always()
37
91
  uses: actions/upload-artifact@v4
@@ -40,6 +94,14 @@ jobs:
40
94
  path: .chant/pr
41
95
  if-no-files-found: ignore
42
96
  include-hidden-files: true
97
+ - name: Post the plan note and chant/plan
98
+ if: always()
99
+ env:
100
+ PR_NUMBER: '${{ github.event.pull_request.number }}'
101
+ HEAD_SHA: '${{ github.sha }}'
102
+ PLAN_RESULT: '${{ needs.plan.result }}'
103
+ GITHUB_TOKEN: '${{ github.token }}'
104
+ run: chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge github --result "$PLAN_RESULT"
43
105
  record:
44
106
  if: github.event_name == 'push'
45
107
  runs-on: ubuntu-latest