@intentius/chant-lexicon-github 0.124.40 → 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.
@@ -24,20 +24,74 @@ 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: Plan the members this pull request reaches
62
+ id: plan
35
63
  env:
36
64
  BASE_SHA: '${{ github.event.pull_request.base.sha }}'
37
65
  PR_NUMBER: '${{ github.event.pull_request.number }}'
38
- GITHUB_TOKEN: '${{ github.token }}'
39
66
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
40
- run: chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github --wave-gate release
67
+ run: |
68
+ 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
69
+ chant_report="$PWD/.chant/pr"
70
+ 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; }
71
+ trap chant_handover EXIT
72
+ chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github --wave-gate release
73
+ plan-note:
74
+ needs:
75
+ - plan
76
+ if: always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')
77
+ runs-on: ubuntu-latest
78
+ container: node:22
79
+ permissions:
80
+ actions: read
81
+ contents: read
82
+ pull-requests: write
83
+ statuses: write
84
+ steps:
85
+ - name: Check out the target branch's commit
86
+ uses: actions/checkout@v7
87
+ with:
88
+ ref: '${{ github.event.pull_request.base.sha }}'
89
+ - name: Take the plan report from the plan job
90
+ run: |
91
+ set -u
92
+ 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'
93
+ ${{ toJSON(needs.plan.outputs.report_0) }}
94
+ END_OF_REPORT
41
95
  - name: Keep the plan report
42
96
  if: always()
43
97
  uses: actions/upload-artifact@v4
@@ -46,6 +100,14 @@ jobs:
46
100
  path: .chant/pr
47
101
  if-no-files-found: ignore
48
102
  include-hidden-files: true
103
+ - name: Post the plan note and chant/plan
104
+ if: always()
105
+ env:
106
+ PR_NUMBER: '${{ github.event.pull_request.number }}'
107
+ HEAD_SHA: '${{ github.sha }}'
108
+ PLAN_RESULT: '${{ needs.plan.result }}'
109
+ GITHUB_TOKEN: '${{ github.token }}'
110
+ run: chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge github --result "$PLAN_RESULT"
49
111
  record:
50
112
  if: github.event_name == 'push'
51
113
  runs-on: ubuntu-latest
@@ -126,7 +188,7 @@ jobs:
126
188
  if-no-files-found: ignore
127
189
  include-hidden-files: true
128
190
  apply-wave-2:
129
- if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
191
+ if: '!cancelled() && needs.apply-wave-1.result != ''skipped'' && (github.event_name == ''workflow_run'' || github.event_name == ''workflow_dispatch'')'
130
192
  runs-on: ubuntu-latest
131
193
  needs:
132
194
  - apply-wave-1
@@ -176,7 +238,7 @@ jobs:
176
238
  if-no-files-found: ignore
177
239
  include-hidden-files: true
178
240
  apply-wave-3:
179
- if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
241
+ if: '!cancelled() && needs.apply-wave-2.result != ''skipped'' && (github.event_name == ''workflow_run'' || github.event_name == ''workflow_dispatch'')'
180
242
  runs-on: ubuntu-latest
181
243
  needs:
182
244
  - apply-wave-2
@@ -24,20 +24,74 @@ 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: Plan the members this pull request reaches
62
+ id: plan
35
63
  env:
36
64
  BASE_SHA: '${{ github.event.pull_request.base.sha }}'
37
65
  PR_NUMBER: '${{ github.event.pull_request.number }}'
38
- GITHUB_TOKEN: '${{ github.token }}'
39
66
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
40
- run: chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github
67
+ run: |
68
+ 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
69
+ chant_report="$PWD/.chant/pr"
70
+ 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; }
71
+ trap chant_handover EXIT
72
+ chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github
73
+ plan-note:
74
+ needs:
75
+ - plan
76
+ if: always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')
77
+ runs-on: ubuntu-latest
78
+ container: node:22
79
+ permissions:
80
+ actions: read
81
+ contents: read
82
+ pull-requests: write
83
+ statuses: write
84
+ steps:
85
+ - name: Check out the target branch's commit
86
+ uses: actions/checkout@v7
87
+ with:
88
+ ref: '${{ github.event.pull_request.base.sha }}'
89
+ - name: Take the plan report from the plan job
90
+ run: |
91
+ set -u
92
+ 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'
93
+ ${{ toJSON(needs.plan.outputs.report_0) }}
94
+ END_OF_REPORT
41
95
  - name: Keep the plan report
42
96
  if: always()
43
97
  uses: actions/upload-artifact@v4
@@ -46,6 +100,14 @@ jobs:
46
100
  path: .chant/pr
47
101
  if-no-files-found: ignore
48
102
  include-hidden-files: true
103
+ - name: Post the plan note and chant/plan
104
+ if: always()
105
+ env:
106
+ PR_NUMBER: '${{ github.event.pull_request.number }}'
107
+ HEAD_SHA: '${{ github.sha }}'
108
+ PLAN_RESULT: '${{ needs.plan.result }}'
109
+ GITHUB_TOKEN: '${{ github.token }}'
110
+ run: chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge github --result "$PLAN_RESULT"
49
111
  record:
50
112
  if: github.event_name == 'push'
51
113
  runs-on: ubuntu-latest
@@ -46,6 +46,19 @@ export function keepRecordStep(name: string, artifact: string, path: string): Re
46
46
  };
47
47
  }
48
48
 
49
+ /**
50
+ * The `if:` of a wave job after the first (#4291): it runs when the wave
51
+ * before it failed, so a failed member blocks only the members that depend
52
+ * on it, and the next wave's job blocks those and runs the rest. It does not
53
+ * run when the run was cancelled, or when the job before it was skipped
54
+ * (its own condition was false). `condition` is the job's own condition,
55
+ * which still has to hold.
56
+ */
57
+ export function afterEarlierWave(previous: string, condition?: string): string {
58
+ const runs = `!cancelled() && needs.${previous}.result != 'skipped'`;
59
+ return condition ? `${runs} && (${condition})` : runs;
60
+ }
61
+
49
62
  /** A `concurrency:` mapping: one run at a time per group. */
50
63
  export function concurrency(group: string, cancelInProgress = false): Record<string, unknown> {
51
64
  return { group, "cancel-in-progress": cancelInProgress };
@@ -434,6 +434,9 @@ describe("generateGithubPipeline: gated waves (#3049)", () => {
434
434
  const parsed = parseYAML(result.yaml) as { jobs: Record<string, { needs?: string[]; steps: Array<Record<string, unknown>> }> };
435
435
  expect(Object.keys(parsed.jobs)).toEqual(["wave-1", "wave-2", "wave-3"]);
436
436
  expect(parsed.jobs["wave-2"].needs).toEqual(["wave-1"]);
437
+ // #4291: wave 2 runs after wave 1 failed, so a failure blocks only its dependents.
438
+ expect((parsed.jobs["wave-2"] as Record<string, unknown>).if).toBe("!cancelled() && needs.wave-1.result != 'skipped'");
439
+ expect((parsed.jobs["wave-1"] as Record<string, unknown>).if).toBeUndefined();
437
440
 
438
441
  const steps = parsed.jobs["wave-2"].steps;
439
442
  expect(steps[0].with).toEqual({ "fetch-depth": 0 });
@@ -59,7 +59,9 @@
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 {
64
66
  GREEN_TAG_PATTERN,
65
67
  GREEN_TICK_WORKFLOW,
@@ -70,9 +72,15 @@ import {
70
72
  prApplyRecordKey,
71
73
  prApplyWaveJobName,
72
74
  prLoopJobs,
75
+ prLoopPostJob,
73
76
  prLoopSetup,
77
+ PR_PLAN_NOTE_JOB,
74
78
  prLoopWaveJobs,
75
79
  prLoopWithWaves,
80
+ prEnvJobs,
81
+ PR_ENV_REPORT_DIR,
82
+ PR_ENV_SWEEP_CRON,
83
+ PR_ENV_POST_JOB,
76
84
  } from "@intentius/chant/components/pr-pipeline";
77
85
  import { memberRepoPath } from "@intentius/chant/lexicon";
78
86
  import type {
@@ -81,7 +89,8 @@ import type {
81
89
  ComponentPipelineResult as GenerateGithubResult,
82
90
  PipelineMember,
83
91
  } from "@intentius/chant/lexicon";
84
- import { checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs";
92
+ import { afterEarlierWave, checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs";
93
+ import { actionRef } from "../action-pins";
85
94
 
86
95
  export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
87
96
 
@@ -122,6 +131,9 @@ export interface GithubPipelineDoc {
122
131
  jobs: GeneratedJob[];
123
132
  }
124
133
 
134
+ /** The plan job's last step, whose outputs carry its report to the plan-note job (#4295). */
135
+ const PLAN_STEP_ID = "plan";
136
+
125
137
  /** The output a gated apply wave hands the green commit it applies through, and the step that sets it (#4221). */
126
138
  const WAVE_GREEN_OUTPUT = "green-sha";
127
139
  const WAVE_STEP_ID = "apply";
@@ -158,6 +170,8 @@ function gatedWaveGithubDoc(
158
170
  steps.push(...runSteps(prLoopSetup(image)), ...runSteps(beforeScript), { run: job.command.join(" ") }, ...runSteps(extraScript));
159
171
  steps.push(keepRecordStep(`Upload the record for wave ${job.wave}`, waveRecordArtifact(job.wave), GATED_WAVE_RECORD));
160
172
  jobsDoc[job.jobName] = {
173
+ // #4291: a wave runs after one that failed, and blocks only what depends on the failure.
174
+ ...(job.needs.length > 0 ? { if: afterEarlierWave(job.needs[0]!) } : {}),
161
175
  "runs-on": GITHUB_RUNNER,
162
176
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
163
177
  container: image,
@@ -203,6 +217,26 @@ function gatedWaveGithubDoc(
203
217
  * for the pushed commit, so wave k reads what wave k-1 left, and a re-run of
204
218
  * a wave that stopped at its gate reads what it left itself. Each keeps its
205
219
  * report as `chant-pr-apply-wave-<k>`.
220
+ *
221
+ * The plan job holds no forge token while the pull request's code runs
222
+ * (#4295, `@intentius/chant/ci-handover`). It checks the change out with a
223
+ * script that gives the job's token to its fetches alone, starts every later
224
+ * step again without the token variables, and ends with the plan step, which
225
+ * hands its report on as the job's outputs. The `plan-note` job then checks
226
+ * out the target branch's commit, takes the report back, keeps it as the
227
+ * `chant-pr-plan` artifact, and posts the note and `chant/plan` with
228
+ * `chant components pr-post`. Only the note job may write to the pull
229
+ * request and its statuses.
230
+ *
231
+ * With `identity` (#4296, `@intentius/chant/ci-identity`) the plan, record
232
+ * and apply jobs get `id-token: write`, and each starts with a step that
233
+ * trades the job's OIDC token for its cloud identity before anything is
234
+ * checked out: the plan and record jobs for the plan identity, the apply
235
+ * jobs for the apply identity. The trade leaves the credentials in
236
+ * `GITHUB_ENV`. Every later step of the plan job starts without the
237
+ * variables a token is requested with, so the pull request's code holds no
238
+ * OIDC token and cannot request one, and the plan job names no apply
239
+ * identity.
206
240
  */
207
241
  function prLoopGithubDoc(
208
242
  components: DriverComponent[],
@@ -235,8 +269,18 @@ function prLoopGithubDoc(
235
269
  uses: "actions/cache/save@v4",
236
270
  with: { path: recordPath, key: recordKey(wave) },
237
271
  });
272
+ // #4296: the step that trades the job's OIDC token for its cloud identity, before the checkout.
273
+ const identity = loop.identity;
274
+ const tradeStep = (spec: CloudTradeSpec, which: "plan" | "apply"): Record<string, unknown> => ({
275
+ name: `Trade the job's OIDC token for the ${which} identity`,
276
+ ...(rooted ? {} : { "working-directory": "." }),
277
+ env: { [TRADE_SPEC_ENV]: JSON.stringify(spec) },
278
+ run: tradeLine("$GITHUB_ENV", "github-env"),
279
+ });
280
+ const withIdToken = (permissions: Record<string, string>): Record<string, string> => (identity ? { ...permissions, "id-token": "write" } : permissions);
238
281
  const reportName = (job: typeof plan) => `chant-pr-${job.wave ? prApplyWaveJobName(job.wave) : job.jobName}`;
239
282
  const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
283
+ ...(identity ? [tradeStep(job.jobName === "record" ? identity.plan : identity.apply, job.jobName === "record" ? "plan" : "apply")] : []),
240
284
  checkoutStep(true),
241
285
  ...runSteps(prLoopSetup(image)),
242
286
  ...runSteps(beforeScript),
@@ -267,12 +311,13 @@ function prLoopGithubDoc(
267
311
  const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : "github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'";
268
312
  const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
269
313
  const applyJob = (job: typeof plan, needs: string[], name: string) => ({
270
- if: job.jobName === "record" ? onPush : onGreen,
314
+ // #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
315
+ if: job.jobName === "record" ? onPush : needs.length > 0 && job.wave ? afterEarlierWave(needs[0]!, onGreen) : onGreen,
271
316
  "runs-on": GITHUB_RUNNER,
272
317
  ...(needs.length > 0 ? { needs } : {}),
273
318
  container: image,
274
319
  concurrency: concurrency(prApplyGroup(env, member?.name)),
275
- permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
320
+ permissions: withIdToken({ contents: "write", "pull-requests": "write", statuses: "write" }),
276
321
  ...(job.wave ? { outputs: { [WAVE_GREEN_OUTPUT]: `\${{ steps.${WAVE_STEP_ID}.outputs.${WAVE_GREEN_OUTPUT} }}` } } : {}),
277
322
  steps: steps(job, name),
278
323
  });
@@ -287,31 +332,84 @@ function prLoopGithubDoc(
287
332
  ? [["apply", applyJob(apply, [], "Apply the newest green commit under the merged changes' approvals")]]
288
333
  : [];
289
334
  const applyJobs: Array<[string, Record<string, unknown>]> = [recordJob, ...greenJobs];
335
+ // #4295: the plan job runs the pull request's code and holds no forge token while it does.
336
+ const forgeName = loop.forge ?? "github";
337
+ // #4296: once the plan job has traded its OIDC token, its later steps start without the variables a token is requested with.
338
+ const drop = dropForgeTokens("sh", identity ? OIDC_REQUEST_VARS : []);
339
+ const headStep = (line: string, name?: string): Record<string, unknown> => ({ ...(name ? { name } : {}), run: `${drop}\n${line}` });
340
+ const planSteps: Array<Record<string, unknown>> = [
341
+ // #4296: the trade runs first, before the change is checked out, with the plan identity alone.
342
+ ...(identity ? [tradeStep(identity.plan, "plan")] : []),
343
+ // A script, not actions/checkout, whose post step runs at the job's end with the token as its input.
344
+ {
345
+ name: "Check out the change",
346
+ ...(rooted ? {} : { "working-directory": "." }),
347
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
348
+ run: checkoutScript(true, true),
349
+ },
350
+ ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
351
+ ...(plan.build ? [headStep(plan.build[0]!, "Build the templates"), headStep(plan.build[1]!, "Lint the project")] : []),
352
+ // The job's last step: a step after it would start beside whatever the change's code left running.
353
+ {
354
+ name: "Plan the members this pull request reaches",
355
+ id: PLAN_STEP_ID,
356
+ env: plan.run.env,
357
+ run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), ...(plan.checkout ?? []), ...(plan.setup ?? []), plan.run.command, ...extraScript].join("\n"),
358
+ },
359
+ ];
360
+ const post = prLoopPostJob(forgeName, env, member?.name);
361
+ const noteSteps: Array<Record<string, unknown>> = [
362
+ // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
363
+ { name: "Check out the target branch's commit", uses: actionRef("actions/checkout"), with: { ref: "${{ github.event.pull_request.base.sha }}" } },
364
+ ...runSteps(beforeScript),
365
+ { name: "Take the plan report from the plan job", run: takeOverScript("plan", PR_LOOP_REPORT_DIR, forgeName) },
366
+ { 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 } },
367
+ { name: "Post the plan note and chant/plan", if: "always()", env: post.env, run: post.command },
368
+ ];
369
+ // #4298: with a pull request environment the pull_request trigger fires on close too, and the plan skips that event.
370
+ const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
290
371
  const jobsDoc: Record<string, unknown> = {
291
372
  plan: {
292
- if: "github.event_name == 'pull_request'",
373
+ if: prEnv ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
293
374
  "runs-on": GITHUB_RUNNER,
294
375
  container: image,
295
- permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
296
- steps: steps(plan, "Plan the members this pull request reaches"),
376
+ permissions: withIdToken({ contents: "read" }),
377
+ outputs: handoverOutputs(PLAN_STEP_ID, forgeName),
378
+ steps: planSteps,
379
+ },
380
+ [PR_PLAN_NOTE_JOB]: {
381
+ needs: ["plan"],
382
+ if: "always() && github.event_name == 'pull_request' && (needs.plan.result == 'success' || needs.plan.result == 'failure')",
383
+ "runs-on": GITHUB_RUNNER,
384
+ container: image,
385
+ // #4289: `actions: read` lets pr-post ask when each scheduled watch or reconcile Op last ran.
386
+ permissions: { actions: "read", contents: "read", "pull-requests": "write", statuses: "write" },
387
+ steps: noteSteps,
297
388
  },
298
389
  ...Object.fromEntries(applyJobs),
390
+ ...(prEnv ? prEnv.jobs : {}),
299
391
  };
300
392
  const applyNames = applyJobs.map(([name]) => name);
393
+ const on: Record<string, unknown> = !loop.applyOnMerge
394
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch] } }
395
+ : forgejo
396
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch], tags: [GREEN_TAG_PATTERN] } }
397
+ : { pull_request: { branches: [branch] }, push: { branches: [branch] }, workflow_run: { workflows: [GREEN_TICK_WORKFLOW], types: ["completed"] }, workflow_dispatch: {} };
398
+ if (prEnv) {
399
+ on.pull_request = { branches: [branch], types: ["opened", "synchronize", "reopened", "closed"] };
400
+ on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
401
+ }
301
402
  return {
302
403
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
303
404
  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: {} },
405
+ on,
309
406
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
310
407
  env: { ...options.variables, CHANT_ENV: env },
311
408
  jobsDoc,
312
- stages: ["plan", ...applyNames],
409
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...applyNames, ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
313
410
  jobs: [
314
411
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
412
+ { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
315
413
  ...applyNames.map((name, i) => ({
316
414
  jobName: name,
317
415
  component: name === "record" ? "merge record" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
@@ -319,10 +417,120 @@ function prLoopGithubDoc(
319
417
  // The record runs on a push and the apply on a green commit, so only the waves need each other.
320
418
  needs: i > 1 ? [applyNames[i - 1]] : [],
321
419
  })),
420
+ ...(prEnv ? prEnv.listed : []),
421
+ ],
422
+ };
423
+ }
424
+
425
+ /**
426
+ * The jobs of a pull request's environment (#4298). `env-up` runs on an
427
+ * opened, updated or reopened pull request and runs the change's code, so it
428
+ * follows the plan job's handover (#4295): a checkout script that alone sees
429
+ * the job's token, every later step started without the token variables,
430
+ * and the `pr-env up` step last, handing its report and record on as the
431
+ * job's outputs. `env-post` checks out the target branch's commit, takes
432
+ * them back, keeps the record on chant/lifecycle and posts the note.
433
+ * `env-down` runs when the pull request closes and `env-sweep` on the
434
+ * schedule, both from the base branch's code: a teardown reads the markers
435
+ * off the live resources and builds nothing of the change. `env-up` and
436
+ * `env-down` run one at a time per pull request, and a close waits for an
437
+ * apply still running rather than cancelling it.
438
+ */
439
+ function prEnvGithubJobs(
440
+ forge: "github" | "forgejo",
441
+ image: string,
442
+ beforeScript: string[],
443
+ extraScript: string[],
444
+ loop: NonNullable<GenerateGithubOptions["prLoop"]>,
445
+ member: GenerateGithubOptions["member"],
446
+ ): { jobs: Record<string, unknown>; listed: GeneratedJob[] } {
447
+ const [up, post, down, sweep] = prEnvJobs(forge, loop, member?.name);
448
+ const rooted = !member || member.dir === "." || member.dir === "";
449
+ const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
450
+ const prNumber = "${{ github.event.pull_request.number }}";
451
+ const group = `chant-pr-env-${member ? `${member.name}-` : ""}${prNumber}`;
452
+ const writes = { contents: "write", "pull-requests": "write", statuses: "write" };
453
+ 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 } });
454
+ const opened = "github.event_name == 'pull_request' && github.event.action != 'closed'";
455
+
456
+ // #4295: env-up runs the change's code and holds no forge token while it does.
457
+ const drop = dropForgeTokens("sh");
458
+ const headStep = (line: string, name?: string, env?: Record<string, string>): Record<string, unknown> => ({ ...(name ? { name } : {}), ...(env ? { env } : {}), run: `${drop}\n${line}` });
459
+ const upJob = {
460
+ if: opened,
461
+ "runs-on": GITHUB_RUNNER,
462
+ container: image,
463
+ concurrency: concurrency(group),
464
+ permissions: { contents: "read" },
465
+ outputs: handoverOutputs(PR_ENV_UP_STEP_ID, forge),
466
+ steps: [
467
+ {
468
+ name: "Check out the change",
469
+ ...(rooted ? {} : { "working-directory": "." }),
470
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
471
+ run: checkoutScript(true, true),
472
+ },
473
+ ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
474
+ ...(up.build ? [headStep(up.build[0]!, "Build the templates", { CHANT_ENV: up.env.CHANT_ENV! }), headStep(up.build[1]!, "Lint the project")] : []),
475
+ // The job's last step: a step after it would start beside whatever the change's code left running.
476
+ {
477
+ name: "Apply this pull request's environment once its plan is approved",
478
+ id: PR_ENV_UP_STEP_ID,
479
+ env: up.env,
480
+ run: [drop, ...handoverTrap(PR_ENV_REPORT_DIR, forge), up.command, ...extraScript].join("\n"),
481
+ },
482
+ ],
483
+ };
484
+ const postJob = {
485
+ needs: ["env-up"],
486
+ if: `always() && ${opened} && (needs.env-up.result == 'success' || needs.env-up.result == 'failure')`,
487
+ "runs-on": GITHUB_RUNNER,
488
+ container: image,
489
+ concurrency: concurrency(`chant-pr-env-post-${member ? `${member.name}-` : ""}${prNumber}`),
490
+ permissions: writes,
491
+ steps: [
492
+ // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
493
+ { name: "Check out the target branch's commit", uses: actionRef("actions/checkout"), with: { ref: "${{ github.event.pull_request.base.sha }}", "fetch-depth": 0 } },
494
+ ...runSteps(beforeScript),
495
+ { name: "Take the environment's report from the env-up job", run: takeOverScript("env-up", PR_ENV_REPORT_DIR, forge) },
496
+ keep("env-up"),
497
+ { name: "Keep the environment's record and post its note", if: "always()", env: post.env, run: post.command },
498
+ ],
499
+ };
500
+ const baseJob = (j: typeof up, condition: string, ref: string, name: string, groupName: string) => ({
501
+ if: condition,
502
+ "runs-on": GITHUB_RUNNER,
503
+ container: image,
504
+ concurrency: concurrency(groupName),
505
+ permissions: writes,
506
+ steps: [
507
+ { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
508
+ ...runSteps(prLoopSetup(image)),
509
+ ...runSteps(beforeScript),
510
+ { name, env: j.env, run: j.command },
511
+ ...runSteps(extraScript),
512
+ keep(j.jobName),
513
+ ],
514
+ });
515
+ return {
516
+ jobs: {
517
+ "env-up": upJob,
518
+ [PR_ENV_POST_JOB]: postJob,
519
+ "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),
520
+ "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}` : ""}`),
521
+ },
522
+ listed: [
523
+ { jobName: "env-up", component: "pull request environment", stage: "env-up", needs: [] },
524
+ { jobName: PR_ENV_POST_JOB, component: "pull request environment note", stage: PR_ENV_POST_JOB, needs: ["env-up"] },
525
+ { jobName: "env-down", component: "pull request environment teardown", stage: "env-down", needs: [] },
526
+ { jobName: "env-sweep", component: "pull request environment sweep", stage: "env-sweep", needs: [] },
322
527
  ],
323
528
  };
324
529
  }
325
530
 
531
+ /** The id of the `pr-env up` step, whose outputs carry the report (#4295). */
532
+ const PR_ENV_UP_STEP_ID = "env-up";
533
+
326
534
  /** 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
535
  function outputsFile(name: string): string {
328
536
  return `${name}.outputs.json`;