@intentius/chant-lexicon-github 0.124.55 → 0.124.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/components/generate-op-pipeline.d.ts +2 -2
  2. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  3. package/dist/components/generate-op-pipeline.js +73 -8
  4. package/dist/components/generate-op-waves-pipeline.d.ts +16 -3
  5. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  6. package/dist/components/generate-op-waves-pipeline.js +82 -17
  7. package/dist/components/generate-pipeline.d.ts.map +1 -1
  8. package/dist/components/generate-pipeline.js +34 -6
  9. package/dist/integrity.json +2 -2
  10. package/dist/manifest.json +1 -1
  11. package/package.json +2 -2
  12. package/src/components/__golden__/op-waves-jobs.yml +31 -4
  13. package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
  14. package/src/components/generate-op-pipeline.ts +89 -15
  15. package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
  16. package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
  17. package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
  18. package/src/components/generate-op-waves-pipeline.test.ts +19 -3
  19. package/src/components/generate-op-waves-pipeline.ts +85 -15
  20. package/src/components/generate-pipeline.ts +38 -6
  21. package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
  22. package/src/components/generate-pr-pipeline.locks.test.ts +10 -0
  23. package/src/components/generate-pr-pipeline.respond.test.ts +47 -0
  24. package/src/components/generate-pr-pipeline.runners.test.ts +61 -0
  25. package/src/components/generate-pr-pipeline.stale-notes.test.ts +42 -0
@@ -6,10 +6,6 @@ on:
6
6
  - main
7
7
  workflow_dispatch: {}
8
8
 
9
- concurrency:
10
- group: migrations
11
- cancel-in-progress: false
12
-
13
9
  permissions:
14
10
  contents: write
15
11
 
@@ -19,7 +15,14 @@ jobs:
19
15
  - self-hosted
20
16
  - dev
21
17
  container: node:22
18
+ timeout-minutes: 180
19
+ env:
20
+ CHANT_LOCK_WAIT: '10500'
22
21
  steps:
22
+ - name: Check the job runs where the spec set it to run
23
+ env:
24
+ CHANT_JOB_GUARD: '{"forge":"github","job":"wave 1 (dev) of migrations","branches":["main"],"selfHosted":true}'
25
+ run: '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
23
26
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24
27
  with:
25
28
  fetch-depth: 0
@@ -43,10 +46,16 @@ jobs:
43
46
  permissions:
44
47
  contents: write
45
48
  id-token: write
49
+ timeout-minutes: 180
46
50
  env:
51
+ CHANT_LOCK_WAIT: '10500'
47
52
  STAGE: apply
48
53
  PLAN_TOKEN_AUDIENCE: plan.example
49
54
  steps:
55
+ - name: Check the job runs where the spec set it to run
56
+ env:
57
+ CHANT_JOB_GUARD: '{"forge":"github","job":"the deciding job of wave 2 (prod) of migrations","branches":["main"]}'
58
+ run: '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
50
59
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
51
60
  with:
52
61
  fetch-depth: 0
@@ -87,11 +96,17 @@ jobs:
87
96
  permissions:
88
97
  contents: write
89
98
  id-token: write
99
+ timeout-minutes: 180
90
100
  env:
101
+ CHANT_LOCK_WAIT: '10500'
91
102
  STAGE: apply
92
103
  REGION: us
93
104
  CLOUD_TOKEN_AUDIENCE: us.example
94
105
  steps:
106
+ - name: Check the job runs where the spec set it to run
107
+ env:
108
+ CHANT_JOB_GUARD: '{"forge":"github","job":"share 1 of job group us of wave 2 (prod) of migrations","forgeEnvironment":"prod-us","branches":["main"]}'
109
+ run: '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
95
110
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
96
111
  with:
97
112
  fetch-depth: 0
@@ -129,11 +144,17 @@ jobs:
129
144
  permissions:
130
145
  contents: write
131
146
  id-token: write
147
+ timeout-minutes: 180
132
148
  env:
149
+ CHANT_LOCK_WAIT: '10500'
133
150
  STAGE: apply
134
151
  REGION: us
135
152
  CLOUD_TOKEN_AUDIENCE: us.example
136
153
  steps:
154
+ - name: Check the job runs where the spec set it to run
155
+ env:
156
+ CHANT_JOB_GUARD: '{"forge":"github","job":"share 2 of job group us of wave 2 (prod) of migrations","forgeEnvironment":"prod-us","branches":["main"]}'
157
+ run: '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
137
158
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
138
159
  with:
139
160
  fetch-depth: 0
@@ -168,10 +189,16 @@ jobs:
168
189
  permissions:
169
190
  contents: write
170
191
  id-token: write
192
+ timeout-minutes: 180
171
193
  env:
194
+ CHANT_LOCK_WAIT: '10500'
172
195
  STAGE: apply
173
196
  PLAN_TOKEN_AUDIENCE: plan.example
174
197
  steps:
198
+ - name: Check the job runs where the spec set it to run
199
+ env:
200
+ CHANT_JOB_GUARD: '{"forge":"github","job":"job group eu of wave 2 (prod) of migrations","forgeEnvironment":"prod-eu","branches":["main"]}'
201
+ run: '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
175
202
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
176
203
  with:
177
204
  fetch-depth: 0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * #4631: cloud identities on a scheduled Op's job, traded before the
3
+ * checkout, and an update on drift in a job of its own that alone holds
4
+ * the update's identity.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import { parseYAML } from "@intentius/chant/yaml";
8
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
9
+ import { generateGithubOpPipeline } from "./generate-op-pipeline";
10
+
11
+ interface Step { id?: string; name?: string; uses?: string; run?: string; env?: Record<string, string> }
12
+ interface Job { needs?: string; if?: string; permissions?: Record<string, string>; outputs?: Record<string, string>; environment?: unknown; steps: Step[] }
13
+ interface Doc { permissions?: Record<string, string>; jobs: Record<string, Job> }
14
+
15
+ const PREVIEW = { aws: { role: "arn:aws:iam::1:role/preview", region: "us-east-1", audience: "sts.amazonaws.com" } };
16
+ const UPDATE = { aws: { role: "arn:aws:iam::1:role/update", region: "us-east-1", audience: "sts.amazonaws.com" } };
17
+ const DRIFT: ScheduledOpSpec = { name: "plywood-drift-dev", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", cloud: PREVIEW };
18
+
19
+ const gen = (spec: ScheduledOpSpec) => parseYAML(generateGithubOpPipeline([spec]).files[0]!.yaml) as unknown as Doc;
20
+
21
+ describe("a scheduled Op's cloud identities (#4631)", () => {
22
+ test("the trade runs before the checkout, and every later run step drops the token request variables", () => {
23
+ const doc = gen(DRIFT);
24
+ const job = doc.jobs["plywood-drift-dev"]!;
25
+ expect(job.steps[0]!.name).toBe("Trade the job's OIDC token for its cloud identities");
26
+ expect(JSON.parse(job.steps[0]!.env!.CHANT_CLOUD_TRADE!)).toEqual(PREVIEW);
27
+ expect(job.steps[1]!.uses).toMatch(/^actions\/checkout@/);
28
+ for (const step of job.steps.slice(2)) if (step.run) expect(step.run).toMatch(/^unset ACTIONS_ID_TOKEN_REQUEST_TOKEN ACTIONS_ID_TOKEN_REQUEST_URL/);
29
+ });
30
+
31
+ test("the finding-mode's permissions stay; the job alone adds id-token: write", () => {
32
+ const plain = gen({ ...DRIFT, cloud: undefined } as ScheduledOpSpec);
33
+ const doc = gen(DRIFT);
34
+ expect(doc.permissions).toEqual(plain.permissions);
35
+ expect(doc.jobs["plywood-drift-dev"]!.permissions).toEqual({ ...plain.permissions, "id-token": "write" });
36
+ });
37
+
38
+ test("a malformed cloud and a cloud on a review split are refused", () => {
39
+ expect(() => gen({ ...DRIFT, cloud: { aws: { region: "us-east-1" } } as never })).toThrow(/audience/);
40
+ expect(() => gen({ name: "review", trigger: { kind: "pull_request" }, reviewAgent: true, cloud: PREVIEW })).toThrow(/no cloud identity/);
41
+ });
42
+ });
43
+
44
+ describe("an update on drift (#4631)", () => {
45
+ const spec: ScheduledOpSpec = { ...DRIFT, update: { command: "npx --no-install plywood up", cloud: UPDATE, environment: { name: "production" } } };
46
+ const doc = gen(spec);
47
+ const check = doc.jobs["plywood-drift-dev"]!;
48
+ const update = doc.jobs["plywood-drift-dev-update"]!;
49
+
50
+ test("the check says when it found drift, and holds only its own identity", () => {
51
+ expect(check.outputs).toEqual({ drift: "${{ steps.drift-check.outputs.drift }}" });
52
+ const run = check.steps.find((s) => s.id === "drift-check")!;
53
+ expect(run.run).toContain('if [ "$code" -eq 2 ]; then echo "drift=true"');
54
+ expect(JSON.stringify(check)).not.toContain("role/update");
55
+ });
56
+
57
+ test("the update job runs after a drift, trades for its own identity first, and runs the command", () => {
58
+ expect(update.needs).toBe("plywood-drift-dev");
59
+ expect(update.if).toBe("always() && needs.plywood-drift-dev.outputs.drift == 'true'");
60
+ expect(update.permissions).toEqual({ contents: "read", "id-token": "write" });
61
+ expect(update.environment).toEqual({ name: "production" });
62
+ expect(JSON.parse(update.steps[0]!.env!.CHANT_CLOUD_TRADE!)).toEqual(UPDATE);
63
+ expect(update.steps[1]!.uses).toMatch(/^actions\/checkout@/);
64
+ expect(update.steps.at(-1)!.run).toMatch(/^unset .*\nnpx --no-install plywood up\n?$/);
65
+ });
66
+
67
+ test("an update on a push trigger, or with no command, is refused", () => {
68
+ expect(() => gen({ name: "apply", trigger: { kind: "push" }, update: { command: "x" } })).toThrow(/cron or pull_request/);
69
+ expect(() => gen({ ...DRIFT, update: { command: " " } })).toThrow(/command/);
70
+ });
71
+ });
72
+
73
+ describe("contents: write on the update job (#4631, 2026-10-28 ruling)", () => {
74
+ const plain = gen(DRIFT);
75
+ const spec: ScheduledOpSpec = { ...DRIFT, update: { command: "npx --no-install plywood up", cloud: UPDATE, permissions: { contents: "write" } } };
76
+ const doc = gen(spec);
77
+
78
+ test("the update job may raise contents to write, so it records on chant/lifecycle", () => {
79
+ expect(doc.jobs["plywood-drift-dev-update"]!.permissions).toEqual({ contents: "write", "id-token": "write" });
80
+ });
81
+
82
+ test("the drift check's job and the workflow keep their scopes", () => {
83
+ expect(doc.permissions).toEqual(plain.permissions);
84
+ expect(doc.jobs["plywood-drift-dev"]!.permissions).toEqual(plain.jobs["plywood-drift-dev"]!.permissions);
85
+ expect(doc.jobs["plywood-drift-dev"]!.permissions!.contents).not.toBe("write");
86
+ });
87
+
88
+ test("the identity scoping stays: the check holds the preview identity, the update its own", () => {
89
+ expect(JSON.stringify(doc.jobs["plywood-drift-dev"])).not.toContain("role/update");
90
+ expect(JSON.parse(doc.jobs["plywood-drift-dev-update"]!.steps[0]!.env!.CHANT_CLOUD_TRADE!)).toEqual(UPDATE);
91
+ });
92
+
93
+ test("contents restated at read is still refused", () => {
94
+ expect(() => gen({ ...DRIFT, update: { command: "x", permissions: { contents: "read" } } })).toThrow(/contents may only be raised to write/);
95
+ });
96
+ });
@@ -100,6 +100,9 @@ import { assertReviewSplit, reviewJobs, reviewNoteJobName } from "./review-jobs"
100
100
  import { resolveAgentSpec } from "@intentius/chant/ci-agent";
101
101
  import { activeProductWords } from "@intentius/chant/ci-product-words";
102
102
  import { agentJobName, agentPushJobName, driftRunOutputs, driftRunStep, githubAgentJobs } from "./agent-jobs";
103
+ import { assertScheduledOpCloud, scheduledOpUpdateJobName, withoutTokenRequestLine } from "@intentius/chant/scheduled-op-cloud";
104
+ import { TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
105
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
103
106
 
104
107
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
105
108
 
@@ -138,8 +141,8 @@ export interface GithubOpPipelineDoc {
138
141
  on: Record<string, unknown>;
139
142
  /** The `env:` mapping, when `options.variables` is set. */
140
143
  env?: Record<string, unknown>;
141
- /** The `concurrency:` mapping — one run at a time per Op. */
142
- concurrency: Record<string, unknown>;
144
+ /** The `concurrency:` mapping — one run at a time per Op. Absent on a waves file (ruling for ci-products 2026-10-28). */
145
+ concurrency?: Record<string, unknown>;
143
146
  /**
144
147
  * The `permissions:` mapping for this Op's finding-mode. GitHub-only:
145
148
  * Forgejo Actions ignores `permissions:` entirely, so the forgejo dialect
@@ -437,6 +440,8 @@ export function buildGithubOpPipelineDocs(
437
440
 
438
441
  const words = activeProductWords();
439
442
  for (const spec of ops) {
443
+ // #4631: the job's cloud identities and the update on drift, checked before anything renders.
444
+ assertScheduledOpCloud(spec);
440
445
  // #4616: the comment agent is a workflow of its own, on pull request comments; the spec's trigger is not read.
441
446
  if (spec.agent?.on === "comment") {
442
447
  const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
@@ -489,6 +494,8 @@ export function buildGithubOpPipelineDocs(
489
494
  throw new Error(`Scheduled Op "${spec.name}" sets a drift agent, which follows a drift check's run; give it a cron or pull_request trigger and no reviewAgent.`);
490
495
  }
491
496
  const stepEnv = split ? {} : jobTokenEnv(findingMode !== "report");
497
+ const cloud = spec.cloud;
498
+ const update = spec.update;
492
499
 
493
500
  const setup = spec.setup ?? [];
494
501
  assertSetupSteps(spec.name, setup);
@@ -503,15 +510,19 @@ export function buildGithubOpPipelineDocs(
503
510
  ? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
504
511
  : runParts.join(" ");
505
512
 
506
- const steps: Array<Record<string, unknown>> = [checkoutStep(), ...setup.map(setupStepDoc), ...runSteps(beforeScript)];
507
- steps.push(
513
+ const later: Array<Record<string, unknown>> = [...setup.map(setupStepDoc), ...runSteps(beforeScript)];
514
+ later.push(
508
515
  gated
509
516
  ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
510
517
  : driftAgent
511
518
  ? driftRunStep(invocation, stepEnv, forge)
512
- : { run: invocation, env: stepEnv },
519
+ : update
520
+ ? driftFlagStep(invocation, stepEnv)
521
+ : { run: invocation, env: stepEnv },
513
522
  );
514
- steps.push(...runSteps(extraScript));
523
+ later.push(...runSteps(extraScript));
524
+ // #4631: the trade before the checkout, and every step after it without the token request variables.
525
+ const steps: Array<Record<string, unknown>> = cloud ? [tradeStep(cloud), checkoutStep(), ...later.map(withoutTokenRequest)] : [checkoutStep(), ...later];
515
526
 
516
527
  const review = split
517
528
  ? reviewJobs({
@@ -527,6 +538,12 @@ export function buildGithubOpPipelineDocs(
527
538
  })
528
539
  : undefined;
529
540
 
541
+ const workflowPermissions = mergePermissions(
542
+ spec.name,
543
+ permissionsFor(findingMode, trigger),
544
+ spec.permissions ?? {},
545
+ trigger,
546
+ );
530
547
  const doc: GithubOpPipelineDoc = {
531
548
  name: spec.name,
532
549
  on: onTrigger(trigger),
@@ -534,12 +551,7 @@ export function buildGithubOpPipelineDocs(
534
551
  // One run at a time per Op — a slow audit must not overlap its own next
535
552
  // scheduled trigger. A push Op's `build` (#4169) widens the group.
536
553
  concurrency: concurrency(opConcurrencyGroup(jobName, trigger)),
537
- permissions: mergePermissions(
538
- spec.name,
539
- permissionsFor(findingMode, trigger),
540
- spec.permissions ?? {},
541
- trigger,
542
- ),
554
+ permissions: workflowPermissions,
543
555
  jobsDoc: {
544
556
  [jobName]: {
545
557
  "runs-on": githubRunsOn(spec.runner),
@@ -554,6 +566,8 @@ export function buildGithubOpPipelineDocs(
554
566
  // behind the same reviewer would hold the message back until
555
567
  // somebody had already acted.
556
568
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
569
+ // #4631: the trade needs id-token: write, on this job alone; the finding-mode's scopes stay as they are.
570
+ ...(cloud ? { permissions: { ...workflowPermissions, "id-token": "write" } } : {}),
557
571
  ...(gated
558
572
  ? {
559
573
  outputs: Object.fromEntries(
@@ -565,9 +579,21 @@ export function buildGithubOpPipelineDocs(
565
579
  }
566
580
  : driftAgent
567
581
  ? { outputs: driftRunOutputs(forge) }
568
- : {}),
582
+ : update
583
+ ? { outputs: { drift: `\${{ steps.${DRIFT_FLAG_STEP_ID}.outputs.drift }}` } }
584
+ : {}),
569
585
  steps,
570
586
  },
587
+ ...(update
588
+ ? {
589
+ [scheduledOpUpdateJobName(jobName)]: updateJob(jobName, update, {
590
+ image,
591
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
592
+ setup: setup.map(setupStepDoc),
593
+ beforeScript,
594
+ }),
595
+ }
596
+ : {}),
571
597
  },
572
598
  ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image, spec.runner) } } : {}),
573
599
  };
@@ -629,14 +655,14 @@ function scopeOpFileToMember(file: GithubOpPipelineFile, member: PipelineMember)
629
655
  return [job, { ...rest, defaults: { run: { "working-directory": member.dir } }, steps }];
630
656
  }),
631
657
  );
632
- const group = (file.doc.concurrency as { group?: unknown }).group;
658
+ const group = (file.doc.concurrency as { group?: unknown } | undefined)?.group;
633
659
  return {
634
660
  name,
635
661
  doc: {
636
662
  ...file.doc,
637
663
  ...(file.doc.name ? { name: `${member.name}/${file.doc.name}` } : {}),
638
664
  on,
639
- concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) },
665
+ ...(file.doc.concurrency ? { concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) } } : {}),
640
666
  jobsDoc,
641
667
  },
642
668
  };
@@ -671,3 +697,51 @@ export function generateGithubOpPipeline(
671
697
  jobs,
672
698
  };
673
699
  }
700
+
701
+ /** The id of the run step that says whether an update's drift check found drift (#4631). */
702
+ const DRIFT_FLAG_STEP_ID = "drift-check";
703
+
704
+ /** The Op's run step when an update follows it (#4631): `drift=true` as an output on chant's drift code, and the run's own exit code. */
705
+ function driftFlagStep(invocation: string, env: Record<string, string>): Record<string, unknown> {
706
+ return {
707
+ id: DRIFT_FLAG_STEP_ID,
708
+ env,
709
+ run: ["set +e", invocation, "code=$?", `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "drift=true" >>"$GITHUB_OUTPUT"; fi`, 'exit "$code"'].join("\n"),
710
+ };
711
+ }
712
+
713
+ /** The trade step (#4631), as an op wave job's (#4332): the job's OIDC token for `cloud`, written to GITHUB_ENV. */
714
+ function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
715
+ return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
716
+ }
717
+
718
+ /** A run step after the trade, with the token request variables unset first. A `uses:` step is left as it is. */
719
+ function withoutTokenRequest(step: Record<string, unknown>): Record<string, unknown> {
720
+ return typeof step.run === "string" ? { ...step, run: withoutTokenRequestLine(step.run) } : step;
721
+ }
722
+
723
+ /**
724
+ * The update job (#4631): after the Op's job, only when its run found
725
+ * drift; its own trade for `update.cloud` before its checkout, then the
726
+ * update's command. Its permissions are `contents: read`, `id-token: write`
727
+ * with a cloud, and the update's own scopes, which may raise `contents` to
728
+ * `write` so the update records on `chant/lifecycle` (2026-10-28 ruling).
729
+ * The Op's own job keeps its scopes.
730
+ */
731
+ function updateJob(
732
+ after: string,
733
+ update: NonNullable<ScheduledOpSpec["update"]>,
734
+ i: { image: string; runner?: ScheduledOpSpec["runner"]; setup: Array<Record<string, unknown>>; beforeScript: readonly string[] },
735
+ ): Record<string, unknown> {
736
+ const later: Array<Record<string, unknown>> = [...i.setup, ...runSteps(i.beforeScript), { name: "Update what drifted", run: update.command }];
737
+ return {
738
+ needs: after,
739
+ if: `always() && needs.${after}.outputs.drift == 'true'`,
740
+ "runs-on": githubRunsOn(i.runner),
741
+ container: i.image,
742
+ permissions: { contents: "read", ...(update.cloud ? { "id-token": "write" } : {}), ...(update.permissions ?? {}) },
743
+ ...nonEmpty("env", update.variables),
744
+ ...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
745
+ steps: update.cloud ? [tradeStep(update.cloud), checkoutStep(), ...later.map(withoutTokenRequest)] : [checkoutStep(), ...later],
746
+ };
747
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * #4630: a pr-review wave's plans workflow posts `<product>/approval` on the
3
+ * head after the plans are recorded and on every review, with a token that
4
+ * reads and writes statuses only.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
8
+ import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
9
+
10
+ const spec: OpWavesSpec = {
11
+ name: "plywood-up",
12
+ op: "plywood-up",
13
+ plan: ["./bin/plan", "{target}", "{plan}"],
14
+ waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pr-review" }],
15
+ };
16
+
17
+ describe("the approval status job (#4630)", () => {
18
+ test("runs after record-plans on a push and alone on a review, with a read token", () => {
19
+ const { files } = generateGithubOpWavesPipeline(spec, { specFile: ".chant/op-waves/plywood-up.spec.json", bin: "npx --no-install plywood" });
20
+ const plans = files.find((f) => f.name === "plywood-up-plans.yml")!.yaml;
21
+ expect(plans).toContain("pull_request_review:");
22
+ expect(plans).toMatch(/types:\n\s+- submitted\n\s+- dismissed/);
23
+ expect(plans).toContain("github.event_name == 'pull_request'");
24
+ expect(plans).toMatch(/approval-status:\n(.*\n)*?\s+needs:\n\s+- record-plans/);
25
+ expect(plans).toMatch(/permissions:\n\s+contents: read\n\s+pull-requests: read\n\s+statuses: write/);
26
+ expect(plans).toContain('npx --no-install plywood run wave --spec .chant/op-waves/plywood-up.spec.json --approval-status --pr "$PR_NUMBER" --head "$HEAD_SHA" --base "$BASE_SHA" --forge github');
27
+ expect(plans).toContain("HEAD_SHA: '${{ github.event.pull_request.head.sha }}'");
28
+ expect(plans).toContain("BASE_SHA: '${{ github.event.pull_request.base.sha }}'");
29
+ });
30
+
31
+ test("a spec with no pr-review wave has no plans workflow", () => {
32
+ const { files } = generateGithubOpWavesPipeline({ ...spec, waves: [{ name: "prod", runs: [{ target: "app" }] }] }, { specFile: "waves.json" });
33
+ expect(files.map((f) => f.name)).toEqual(["plywood-up.yml"]);
34
+ });
35
+
36
+ test("a pull-request wave gets the plans workflow and the status too (2026-10-28 ruling)", () => {
37
+ const pullRequest: OpWavesSpec = { ...spec, waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pull-request" }], approvals: { pullRequest: { gate: "up" } } };
38
+ const { files } = generateGithubOpWavesPipeline(pullRequest, { specFile: "waves.json", bin: "npx --no-install plywood" });
39
+ expect(files.map((f) => f.name)).toEqual(["plywood-up.yml", "plywood-up-plans.yml"]);
40
+ const plans = files.find((f) => f.name === "plywood-up-plans.yml")!.yaml;
41
+ expect(plans).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
42
+ expect(plans).toContain("--approval-status --pr \"$PR_NUMBER\"");
43
+ // The waves read no review, so they ask for no pull-requests scope.
44
+ expect(files[0]!.yaml).not.toContain("pull-requests: read");
45
+ });
46
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The apply-job guard in GitHub op-wave jobs (#4624): the first step of
3
+ * every wave, deciding and share job, before the trade and the checkout,
4
+ * carrying the job's forge environment, branches and runner. Written for
5
+ * the ci-products post-merge pass.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
10
+ import { JOB_GUARD_ENV, jobGuardLine } from "@intentius/chant/ci-job-guard";
11
+ import { buildGithubOpWavesDoc } from "./generate-op-waves-pipeline";
12
+
13
+ const spec: OpWavesSpec = {
14
+ name: "deploy",
15
+ op: "pulumi-up",
16
+ plan: ["./bin/plan", "{target}", "{plan}"],
17
+ branches: ["main", "release"],
18
+ runner: ["self-hosted", "linux"],
19
+ waves: [
20
+ { name: "dev", runs: [{ target: "dev" }], gate: "never" },
21
+ { name: "prod", runs: [{ target: "prod" }], environment: { name: "production" }, idTokens: { CLOUD_TOKEN: "sts.amazonaws.com" } },
22
+ { name: "wide", runs: [{ target: "a" }, { target: "b" }], shares: 2, environment: { name: "production" } },
23
+ ],
24
+ };
25
+
26
+ type Job = { steps: Array<{ name?: string; env?: Record<string, string>; run?: string; uses?: string }> };
27
+ const jobsOf = (s: OpWavesSpec, words?: { product: string }) => buildGithubOpWavesDoc(s, { specFile: "waves.json", ...(words ? { words } : {}) }).doc.jobsDoc as Record<string, Job>;
28
+ const guardOf = (job: Job) => JSON.parse(job.steps[0]!.env![JOB_GUARD_ENV]!) as Record<string, unknown>;
29
+
30
+ describe("the apply-job guard on GitHub (#4624)", () => {
31
+ test("every wave job starts with it, before the checkout", () => {
32
+ const jobs = jobsOf(spec);
33
+ for (const [name, job] of Object.entries(jobs)) {
34
+ expect(job.steps[0]!.run, name).toBe(jobGuardLine());
35
+ expect(job.steps[1]!.uses, name).toMatch(/checkout/);
36
+ }
37
+ });
38
+
39
+ test("carries the branches, the runner and, where it can read it, the environment", () => {
40
+ const jobs = jobsOf(spec);
41
+ expect(guardOf(jobs["wave-1-dev"]!)).toEqual({ forge: "github", job: "wave 1 (dev) of deploy", branches: ["main", "release"], selfHosted: true });
42
+ // Its id tokens give the job id-token: write, so the guard reads the environment from a token.
43
+ expect(guardOf(jobs["wave-2-prod"]!)).toMatchObject({ forgeEnvironment: "production" });
44
+ // A job that may request no token cannot be checked for its environment on GitHub.
45
+ expect(guardOf(jobs["wave-3-wide-decide"]!)).not.toHaveProperty("forgeEnvironment");
46
+ expect(guardOf(jobs["wave-3-wide-share-2"]!)).toMatchObject({ job: "share 2 of wave 3 (wide) of deploy" });
47
+ });
48
+
49
+ test("names the product", () => {
50
+ expect(guardOf(jobsOf(spec, { product: "plywood" })["wave-1-dev"]!)).toMatchObject({ product: "plywood" });
51
+ });
52
+
53
+ test("the default branch when the spec names none", () => {
54
+ const { branches: _b, ...plain } = spec;
55
+ expect(guardOf(jobsOf(plain)["wave-1-dev"]!)).toMatchObject({ branches: ["main"] });
56
+ });
57
+ });
@@ -57,7 +57,7 @@ interface Job {
57
57
  }
58
58
 
59
59
  const jobsOf = (spec: OpWavesSpec): Record<string, Job> => (parseYAML(generateGithubOpWavesPipeline(spec, { specFile: ".chant/op-waves/tf-apply.spec.json", beforeScript: ["npm ci"] }).files[0]!.yaml) as { jobs: Record<string, Job> }).jobs;
60
- const tradeOf = (job: Job): unknown => JSON.parse(job.steps[0]!.env![TRADE_SPEC_ENV]!);
60
+ const tradeOf = (job: Job): unknown => JSON.parse(job.steps[1]!.env![TRADE_SPEC_ENV]!);
61
61
  const UNSET = `unset ${OIDC_REQUEST_VARS.join(" ")}`;
62
62
 
63
63
  describe("an Op wave's jobs by cloud identity (#4332)", () => {
@@ -70,9 +70,9 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
70
70
 
71
71
  test("the deciding job trades for every plan identity, before the checkout, and names no apply identity", () => {
72
72
  const decide = jobs["wave-1-wave-1-decide"]!;
73
- expect(decide.steps[0]!.name).toBe("Trade the job's OIDC token for its cloud identities");
74
- expect(decide.steps[0]!.run).toMatch(/^\(cd \/ && node -e '/);
75
- expect(decide.steps[1]!.uses).toMatch(/^actions\/checkout@/);
73
+ expect(decide.steps[1]!.name).toBe("Trade the job's OIDC token for its cloud identities");
74
+ expect(decide.steps[1]!.run).toMatch(/^\(cd \/ && node -e '/);
75
+ expect(decide.steps[2]!.uses).toMatch(/^actions\/checkout@/);
76
76
  expect(tradeOf(decide)).toEqual(plan);
77
77
  expect(decide.permissions).toEqual({ contents: "write", "id-token": "write" });
78
78
  for (const id of [role("prod-apply"), role("staging-apply"), role("own-apply")]) expect(JSON.stringify(decide)).not.toContain(id);
@@ -83,14 +83,14 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
83
83
  for (const [name, id] of Object.entries(want)) {
84
84
  const job = jobs[name]!;
85
85
  expect(tradeIdentities(tradeOf(job) as never)).toEqual([id]);
86
- expect(job.steps[1]!.uses).toMatch(/^actions\/checkout@/);
86
+ expect(job.steps[2]!.uses).toMatch(/^actions\/checkout@/);
87
87
  for (const other of Object.values(want).filter((o) => o !== id)) expect(JSON.stringify(job)).not.toContain(other);
88
88
  }
89
89
  });
90
90
 
91
91
  test("every run step after the trade starts by unsetting the token request variables", () => {
92
92
  for (const job of Object.values(jobs)) {
93
- const runs = job.steps.slice(1).filter((s) => s.run !== undefined);
93
+ const runs = job.steps.slice(2).filter((s) => s.run !== undefined);
94
94
  expect(runs.length).toBeGreaterThan(0);
95
95
  for (const s of runs) expect(s.run!.split("\n")[0]).toBe(UNSET);
96
96
  }
@@ -124,8 +124,8 @@ describe("the deciding job, run step by step against the emulator (#4332)", () =
124
124
  const githubEnv = join(temp, "github-env");
125
125
  writeFileSync(githubEnv, "");
126
126
  const base = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", RUNNER_TEMP: temp, ...emu.requestEnv };
127
- const spec = emu.pointAt(JSON.parse(decide.steps[0]!.env![TRADE_SPEC_ENV]!));
128
- await run("sh", ["-c", decide.steps[0]!.run!.replace("$GITHUB_ENV", githubEnv)], { env: { ...base, [TRADE_SPEC_ENV]: JSON.stringify(spec) } });
127
+ const spec = emu.pointAt(JSON.parse(decide.steps[1]!.env![TRADE_SPEC_ENV]!));
128
+ await run("sh", ["-c", decide.steps[1]!.run!.replace("$GITHUB_ENV", githubEnv)], { env: { ...base, [TRADE_SPEC_ENV]: JSON.stringify(spec) } });
129
129
  const traded = Object.fromEntries(readFileSync(githubEnv, "utf-8").split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
130
130
  expect(traded.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("own-plan")).accessKeyId);
131
131
  const before = emu.log.minted.length;
@@ -105,14 +105,14 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
105
105
  const yaml = files[0]!.yaml;
106
106
  expect(yaml).toMatch(/wave-1-dev:\n\s+runs-on:\n\s+- self-hosted\n\s+- dev\n/);
107
107
  expect(yaml).toMatch(
108
- /wave-2-prod-decide:\n\s+runs-on: plan\n[\s\S]*?permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/,
108
+ /wave-2-prod-decide:\n\s+runs-on: plan\n[\s\S]*?permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/,
109
109
  );
110
110
  expect(yaml).toMatch(
111
- /wave-2-prod-us-share-1:\n\s+runs-on:\n\s+group: prod-apply\n\s+labels:\n\s+- linux\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+environment:\n\s+name: prod-us\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/,
111
+ /wave-2-prod-us-share-1:\n\s+runs-on:\n\s+group: prod-apply\n\s+labels:\n\s+- linux\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+environment:\n\s+name: prod-us\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/,
112
112
  );
113
113
  expect(yaml).toContain("- run: echo us\n - name: Apply share 1 of job group us of wave 2 (prod)\n run: chant run wave --spec waves.json --wave 2 --group us --share 1\n");
114
114
  // A group with no id tokens of its own gets the wave's.
115
- expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n[\s\S]*?name: prod-eu\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/);
115
+ expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n[\s\S]*?name: prod-eu\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/);
116
116
  expect(yaml).toContain("run: chant run wave --spec waves.json --wave 2 --group eu\n");
117
117
  });
118
118
 
@@ -139,3 +139,19 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
139
139
  expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
140
140
  });
141
141
  });
142
+
143
+ describe("the ledger lock (ruling for ci-products 2026-10-28)", () => {
144
+ test("the waves workflow carries no concurrency group; each wave job carries its timeout and the lock wait under it", () => {
145
+ const yaml = generateGithubOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
146
+ expect(yaml).not.toMatch(/^concurrency:/m);
147
+ expect(yaml).not.toContain("group: migrations");
148
+ expect(yaml.match(/timeout-minutes: 180\n/g)).toHaveLength(4);
149
+ expect(yaml.match(/CHANT_LOCK_WAIT: '10500'\n/g)).toHaveLength(4);
150
+ });
151
+
152
+ test("jobTimeoutMinutes sets both", () => {
153
+ const yaml = generateGithubOpWavesPipeline(spec, { specFile: "waves.json", jobTimeoutMinutes: 60 }).files[0]!.yaml;
154
+ expect(yaml).toContain("timeout-minutes: 60\n");
155
+ expect(yaml).toContain("CHANT_LOCK_WAIT: '3300'\n");
156
+ });
157
+ });