@intentius/chant-lexicon-github 0.124.54 → 0.124.56

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 (39) hide show
  1. package/dist/components/agent-jobs.d.ts +67 -0
  2. package/dist/components/agent-jobs.d.ts.map +1 -0
  3. package/dist/components/agent-jobs.js +139 -0
  4. package/dist/components/control-pipeline.d.ts +34 -0
  5. package/dist/components/control-pipeline.d.ts.map +1 -0
  6. package/dist/components/control-pipeline.js +45 -0
  7. package/dist/components/generate-op-pipeline.d.ts +8 -1
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +144 -16
  10. package/dist/components/generate-op-waves-pipeline.d.ts +27 -1
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +135 -16
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +160 -15
  15. package/dist/integrity.json +2 -2
  16. package/dist/manifest.json +1 -1
  17. package/package.json +2 -2
  18. package/src/components/__golden__/op-waves-jobs.yml +20 -0
  19. package/src/components/agent-jobs.ts +177 -0
  20. package/src/components/control-pipeline.ts +65 -0
  21. package/src/components/generate-op-pipeline.agent.test.ts +147 -0
  22. package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
  23. package/src/components/generate-op-pipeline.notice.test.ts +2 -1
  24. package/src/components/generate-op-pipeline.ts +165 -20
  25. package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
  26. package/src/components/generate-op-waves-pipeline.control.test.ts +64 -0
  27. package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
  28. package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
  29. package/src/components/generate-op-waves-pipeline.test.ts +17 -0
  30. package/src/components/generate-op-waves-pipeline.ts +157 -15
  31. package/src/components/generate-pipeline.ts +170 -15
  32. package/src/components/generate-pr-pipeline.comment-locks.test.ts +53 -0
  33. package/src/components/generate-pr-pipeline.comment-preview.test.ts +64 -0
  34. package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
  35. package/src/components/generate-pr-pipeline.control.test.ts +94 -0
  36. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +43 -0
  37. package/src/components/generate-pr-pipeline.respond.test.ts +47 -0
  38. package/src/components/generate-pr-pipeline.runners.test.ts +61 -0
  39. package/src/components/generate-pr-pipeline.stale-notes.test.ts +42 -0
@@ -20,6 +20,10 @@ jobs:
20
20
  - dev
21
21
  container: node:22
22
22
  steps:
23
+ - name: Check the job runs where the spec set it to run
24
+ env:
25
+ CHANT_JOB_GUARD: '{"forge":"github","job":"wave 1 (dev) of migrations","branches":["main"],"selfHosted":true}'
26
+ 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
27
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24
28
  with:
25
29
  fetch-depth: 0
@@ -47,6 +51,10 @@ jobs:
47
51
  STAGE: apply
48
52
  PLAN_TOKEN_AUDIENCE: plan.example
49
53
  steps:
54
+ - name: Check the job runs where the spec set it to run
55
+ env:
56
+ CHANT_JOB_GUARD: '{"forge":"github","job":"the deciding job of wave 2 (prod) of migrations","branches":["main"]}'
57
+ 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
58
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
51
59
  with:
52
60
  fetch-depth: 0
@@ -92,6 +100,10 @@ jobs:
92
100
  REGION: us
93
101
  CLOUD_TOKEN_AUDIENCE: us.example
94
102
  steps:
103
+ - name: Check the job runs where the spec set it to run
104
+ env:
105
+ CHANT_JOB_GUARD: '{"forge":"github","job":"share 1 of job group us of wave 2 (prod) of migrations","forgeEnvironment":"prod-us","branches":["main"]}'
106
+ 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
107
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
96
108
  with:
97
109
  fetch-depth: 0
@@ -134,6 +146,10 @@ jobs:
134
146
  REGION: us
135
147
  CLOUD_TOKEN_AUDIENCE: us.example
136
148
  steps:
149
+ - name: Check the job runs where the spec set it to run
150
+ env:
151
+ CHANT_JOB_GUARD: '{"forge":"github","job":"share 2 of job group us of wave 2 (prod) of migrations","forgeEnvironment":"prod-us","branches":["main"]}'
152
+ 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
153
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
138
154
  with:
139
155
  fetch-depth: 0
@@ -172,6 +188,10 @@ jobs:
172
188
  STAGE: apply
173
189
  PLAN_TOKEN_AUDIENCE: plan.example
174
190
  steps:
191
+ - name: Check the job runs where the spec set it to run
192
+ env:
193
+ CHANT_JOB_GUARD: '{"forge":"github","job":"job group eu of wave 2 (prod) of migrations","forgeEnvironment":"prod-eu","branches":["main"]}'
194
+ 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
195
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
176
196
  with:
177
197
  fetch-depth: 0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The agent jobs on GitHub and Forgejo (#4616): `ScheduledOpSpec.agent`.
3
+ * What each job does is @intentius/chant/ci-agent's; this file lays them
4
+ * out so the model never shares a job with a write token:
5
+ *
6
+ * - the agent job holds `contents: read` and `pull-requests: read`. Its
7
+ * checkout script alone sees the job token, for the fetches (the target
8
+ * branch's commit, chant/lifecycle for the run count, and for a comment
9
+ * the pull request's head). `chant agent prepare` reads with the job
10
+ * token; `chant agent run` is the last step and starts without any forge
11
+ * token, with the model's key from its secret, and hands the agent
12
+ * directory on as the job's outputs (`handoverTrap`).
13
+ * - the push job checks out the target branch's commit, takes the agent
14
+ * directory back as data and runs `chant agent land` with the push token:
15
+ * `tokenEnv`'s secret, else the job token. A push made with the job token
16
+ * starts no workflow on GitHub, so the pull request is planned again only
17
+ * with a token of a user or an app.
18
+ *
19
+ * Forgejo's runner ignores `permissions:` and has no author association, so
20
+ * there `chant agent prepare` asks the forge whether the commenter can write,
21
+ * and both jobs drop the token variables before any step that does not need
22
+ * them.
23
+ */
24
+
25
+ import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
26
+ import { githubRunsOn, GITHUB_JOB_TOKEN, githubSecret, githubSecretOrJobToken, type CiRunner } from "@intentius/chant/ci-jobs";
27
+ import {
28
+ AGENT_DECISION_FILE,
29
+ AGENT_DIR,
30
+ AGENT_DRIFT_FILE,
31
+ AGENT_PATCH_FILE,
32
+ AGENT_RC_FILE,
33
+ agentCommandLines,
34
+ agentGitAuthLines,
35
+ agentHeadFetchLine,
36
+ agentSettingsEnv,
37
+ type AgentSettings,
38
+ } from "@intentius/chant/ci-agent";
39
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
40
+
41
+ /** The agent step's id, which the agent job's outputs come from. */
42
+ export const AGENT_STEP_ID = "chant-agent";
43
+ /** The drift Op's run step id, whose outputs carry the drift and the code. */
44
+ export const AGENT_DRIFT_STEP_ID = "chant-run";
45
+ /** The variable the push step holds its token in, for git's header and the forge client. */
46
+ const PUSH_TOKEN_ENV = "CHANT_AGENT_TOKEN";
47
+
48
+ /** The files the handover keeps when the agent directory is too large to hand on whole. */
49
+ const HANDED = { label: "chant-agent", noteFiles: [AGENT_DECISION_FILE, AGENT_RC_FILE, AGENT_PATCH_FILE, "prompt.sha256"] } as const;
50
+ const DRIFT_HANDED = { label: "chant-agent", noteFiles: [AGENT_DRIFT_FILE] } as const;
51
+
52
+ /** The name of the push job beside the agent job. */
53
+ export function agentPushJobName(jobName: string): string {
54
+ return `${jobName}-push`;
55
+ }
56
+
57
+ /** The agent job's name for a drift Op's job. */
58
+ export function agentJobName(jobName: string, on: AgentSettings["on"]): string {
59
+ return on === "drift" ? `${jobName}-agent` : jobName;
60
+ }
61
+
62
+ export interface GithubAgentJobsInput {
63
+ forge: "github" | "forgejo";
64
+ /** The agent job's name. */
65
+ jobName: string;
66
+ image: string;
67
+ settings: AgentSettings;
68
+ runner?: CiRunner;
69
+ /** Setup steps, as built by `setupStepDoc`. */
70
+ setup: Array<Record<string, unknown>>;
71
+ beforeScript: readonly string[];
72
+ /** The secret holding the push token; absent: the job token. */
73
+ tokenEnv?: string;
74
+ /** The command in place of `chant`: the product's bin. */
75
+ bin?: string;
76
+ /** For a drift agent: the Op's job, which handed the drift on. */
77
+ after?: string;
78
+ }
79
+
80
+ /** The comment condition of the agent job: a pull request comment that starts with the agent's line, from a writer on GitHub. */
81
+ export function agentCommentIf(forge: "github" | "forgejo", comment: string): string {
82
+ return [
83
+ "github.event_name == 'issue_comment'",
84
+ forge === "forgejo" ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
85
+ `startsWith(github.event.comment.body, '${comment.replace(/'/g, "''")} ')`,
86
+ ...(forge === "forgejo" ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
87
+ ].join(" && ");
88
+ }
89
+
90
+ /**
91
+ * The drift Op's run step (#4616): the run with its output kept as the
92
+ * drift for the agent, `drift=true` among the step's outputs when it exits
93
+ * with chant's `drift` code, and the output handed on. The step still exits
94
+ * with the run's code.
95
+ */
96
+ export function driftRunStep(invocation: string, env: Record<string, string>, forge: "github" | "forgejo"): Record<string, unknown> {
97
+ return {
98
+ id: AGENT_DRIFT_STEP_ID,
99
+ env,
100
+ run: [
101
+ "set +e",
102
+ `mkdir -p ${AGENT_DIR}`,
103
+ ...handoverTrap(AGENT_DIR, forge, DRIFT_HANDED),
104
+ `{ ${invocation}; echo "$?" >"\${RUNNER_TEMP:-/tmp}/chant-run.code"; } 2>&1 | tee ${AGENT_DIR}/${AGENT_DRIFT_FILE}`,
105
+ 'code=$(cat "${RUNNER_TEMP:-/tmp}/chant-run.code")',
106
+ `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "drift=true" >>"$GITHUB_OUTPUT"; fi`,
107
+ 'exit "$code"',
108
+ ].join("\n"),
109
+ };
110
+ }
111
+
112
+ /** The drift Op job's outputs: the handed-on drift and the drift flag. */
113
+ export function driftRunOutputs(forge: "github" | "forgejo"): Record<string, string> {
114
+ return { ...handoverOutputs(AGENT_DRIFT_STEP_ID, forge), drift: `\${{ steps.${AGENT_DRIFT_STEP_ID}.outputs.drift }}` };
115
+ }
116
+
117
+ /** The agent job and the push job. */
118
+ export function githubAgentJobs(i: GithubAgentJobsInput): { agent: Record<string, unknown>; push: Record<string, unknown> } {
119
+ const s = i.settings;
120
+ const lines = agentCommandLines(i.forge, i.bin);
121
+ const drop = dropForgeTokens("sh");
122
+ const comment = s.on === "comment";
123
+ const settingsEnv = agentSettingsEnv(s);
124
+ const checkout = (head: boolean): Record<string, unknown> => ({
125
+ name: head ? "Check out the target branch's commit and the pull request's head" : "Check out the commit the drift check ran on",
126
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN, ...(head ? { PR_NUMBER: "${{ github.event.issue.number }}" } : {}) },
127
+ // The ledger branch too: prepare counts the agent's runs on it, and land appends to it.
128
+ run: [checkoutScript(true, true), ...(head ? [agentHeadFetchLine(i.forge, "PR_NUMBER")] : [])].join("\n"),
129
+ });
130
+ const before = i.beforeScript.map((line) => ({ run: `${drop}\n${line}` }));
131
+ const timeout = s.timeout + 15;
132
+ const agent: Record<string, unknown> = {
133
+ ...(comment ? { if: agentCommentIf(i.forge, s.comment!) } : { needs: i.after, if: `always() && needs.${i.after}.outputs.drift == 'true'` }),
134
+ "runs-on": githubRunsOn(i.runner),
135
+ container: i.image,
136
+ "timeout-minutes": timeout,
137
+ permissions: { contents: "read", "pull-requests": "read" },
138
+ env: settingsEnv,
139
+ outputs: handoverOutputs(AGENT_STEP_ID, i.forge),
140
+ steps: [
141
+ checkout(comment),
142
+ ...(comment ? [] : [{ name: "Take the drift from the drift check", run: takeOverScript(i.after!, AGENT_DIR, i.forge) }]),
143
+ ...i.setup,
144
+ ...before,
145
+ // Reads only: the comment, the commenter's access, the pull request's head and the ledger.
146
+ { name: "Decide whether the agent runs", env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN }, run: lines.prepare },
147
+ // The job's last step: the model's key and no forge token, and the agent directory handed on however it ends.
148
+ {
149
+ id: AGENT_STEP_ID,
150
+ name: "Run the agent",
151
+ env: { [s.keyEnv]: githubSecret(s.keyEnv) },
152
+ run: [drop, ...handoverTrap(AGENT_DIR, i.forge, HANDED), lines.run].join("\n"),
153
+ },
154
+ ],
155
+ };
156
+ const token = i.tokenEnv ? githubSecretOrJobToken(i.tokenEnv) : GITHUB_JOB_TOKEN;
157
+ const push: Record<string, unknown> = {
158
+ needs: i.jobName,
159
+ if: `always() && needs.${i.jobName}.result == 'success'`,
160
+ "runs-on": githubRunsOn(i.runner),
161
+ container: i.image,
162
+ permissions: { contents: "write", "pull-requests": "write", issues: "write" },
163
+ env: settingsEnv,
164
+ steps: [
165
+ checkout(comment),
166
+ { name: "Take the change from the agent job", run: takeOverScript(i.jobName, AGENT_DIR, i.forge) },
167
+ ...i.setup,
168
+ ...before,
169
+ {
170
+ name: comment ? "Push the change to the pull request" : "Open or update the agent's pull request",
171
+ env: { [PUSH_TOKEN_ENV]: token, GITHUB_TOKEN: token, ...(i.forge === "forgejo" ? { CHANT_FORGEJO_TOKEN: token } : {}) },
172
+ run: [...agentGitAuthLines(PUSH_TOKEN_ENV, "x-access-token"), lines.land].join("\n"),
173
+ },
174
+ ],
175
+ };
176
+ return { agent, push };
177
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The control repo's own workflow (#4568) on GitHub, and on Forgejo through
3
+ * its dialect: a member's dispatch starts it, and it runs the member's
4
+ * dependents with `chant workspace run --dependents-of`. The pieces are
5
+ * core's (`controlPipelineSpec` in `@intentius/chant/control/dispatch`);
6
+ * this file writes them as a workflow. The member's side, the
7
+ * `control-dispatch` job, comes from the pull request loop's `control`
8
+ * option (./generate-pipeline.ts).
9
+ */
10
+
11
+ import { CI_IMAGE, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
12
+ import { controlPipelineSpec } from "@intentius/chant/control/dispatch";
13
+ import type { CiProductWords, CiProductWordsInput } from "@intentius/chant/ci-product-words";
14
+ import { CHECKOUT, concurrency, emitWorkflowYAML, type GithubWorkflowDoc } from "./ci-jobs";
15
+
16
+ /** A control repo's pipeline file: where it goes, its text, and what a person still sets up. */
17
+ export interface ControlPipelineFile {
18
+ path: string;
19
+ content: string;
20
+ notes: string[];
21
+ }
22
+
23
+ /** What {@link controlGithubDoc} and the generators take. */
24
+ export interface ControlPipelineOptions {
25
+ words?: CiProductWordsInput | CiProductWords;
26
+ /** The image the job runs in. Default chant's CI image. */
27
+ image?: string;
28
+ /** The command the job runs chant with. Default `npx --no-install chant`. */
29
+ command?: string;
30
+ }
31
+
32
+ /** The control repo's workflow document for a GitHub-shaped forge, before emission. Forgejo transforms it first. */
33
+ export function controlGithubDoc(forge: "github" | "forgejo", options: ControlPipelineOptions = {}): { doc: GithubWorkflowDoc; path: string; notes: string[] } {
34
+ const spec = controlPipelineSpec(forge, options.words ?? {}, options.command ? { command: options.command } : {});
35
+ const doc: GithubWorkflowDoc = {
36
+ header: spec.header,
37
+ name: spec.path.split("/").pop()!.replace(/\.yml$/, ""),
38
+ on: {
39
+ ...(forge === "github" ? { repository_dispatch: { types: [spec.event] } } : {}),
40
+ workflow_dispatch: { inputs: spec.inputs },
41
+ },
42
+ // #4617: contents: write pushes the record of each dispatch that carries an outcome to chant/lifecycle.
43
+ permissions: { contents: "write" },
44
+ // One run per member at a time, and none cancelled: a later apply of the same member waits for the one running.
45
+ concurrency: concurrency(spec.group),
46
+ jobsDoc: {
47
+ dependents: {
48
+ "runs-on": GITHUB_RUNNER,
49
+ container: options.image ?? CI_IMAGE,
50
+ steps: [
51
+ // The full history, so the job reads and appends to chant/lifecycle (#4617).
52
+ { uses: CHECKOUT, with: { "fetch-depth": 0 } },
53
+ { name: "Run the dependents of the member that applied", shell: "bash", env: spec.env, run: spec.script },
54
+ ],
55
+ },
56
+ },
57
+ };
58
+ return { doc, path: spec.path, notes: spec.notes };
59
+ }
60
+
61
+ /** The control repo's workflow on GitHub: `.github/workflows/<product>-dependents.yml`. */
62
+ export function generateControlPipeline(options: ControlPipelineOptions = {}): ControlPipelineFile {
63
+ const { doc, path, notes } = controlGithubDoc("github", options);
64
+ return { path, content: emitWorkflowYAML(doc), notes };
65
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The agent jobs (#4616): `ScheduledOpSpec.agent` and `runner`. The model's
3
+ * job holds a read token and the model's key; only the push job writes.
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { parseYAML } from "@intentius/chant/yaml";
8
+ import { FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
9
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
10
+ import { generateGithubOpPipeline } from "./generate-op-pipeline";
11
+
12
+ interface Step {
13
+ id?: string;
14
+ name?: string;
15
+ run?: string;
16
+ env?: Record<string, string>;
17
+ }
18
+ interface Job {
19
+ needs?: string;
20
+ if?: string;
21
+ "runs-on"?: unknown;
22
+ "timeout-minutes"?: number;
23
+ permissions?: Record<string, string>;
24
+ env?: Record<string, string>;
25
+ outputs?: Record<string, string>;
26
+ steps: Step[];
27
+ }
28
+ interface Doc {
29
+ on: Record<string, unknown>;
30
+ permissions?: Record<string, string>;
31
+ jobs: Record<string, Job>;
32
+ }
33
+
34
+ const gen = (spec: ScheduledOpSpec) => {
35
+ const out = generateGithubOpPipeline([spec]);
36
+ return { doc: parseYAML(out.files[0]!.yaml) as unknown as Doc, name: out.files[0]!.name, jobs: out.jobs };
37
+ };
38
+
39
+ const COMMENT: ScheduledOpSpec = { name: "chant-agent", agent: { on: "comment", comment: "/chant agent", maxRuns: 3, timeout: 20 } };
40
+
41
+ describe("the comment agent", () => {
42
+ const { doc, name } = gen(COMMENT);
43
+ const agent = doc.jobs["chant-agent"]!;
44
+ const push = doc.jobs["chant-agent-push"]!;
45
+
46
+ test("is a workflow of its own on pull request comments from a writer", () => {
47
+ expect(name).toBe("chant-agent.yml");
48
+ expect(doc.on).toEqual({ issue_comment: { types: ["created"] } });
49
+ expect(agent.if).toContain("startsWith(github.event.comment.body, '/chant agent ')");
50
+ expect(agent.if).toContain("author_association");
51
+ });
52
+
53
+ test("the agent job reads, stops at its timeout, and runs the model with its key and no forge token", () => {
54
+ expect(agent.permissions).toEqual({ contents: "read", "pull-requests": "read" });
55
+ expect(agent["timeout-minutes"]).toBe(35);
56
+ expect(agent.env).toMatchObject({ CHANT_AGENT_NAME: "chant-agent", CHANT_AGENT_ON: "comment", CHANT_AGENT_MAX_RUNS: "3", CHANT_AGENT_TIMEOUT: "20" });
57
+ const run = agent.steps.at(-1)!;
58
+ expect(run.id).toBe("chant-agent");
59
+ expect(run.env).toEqual({ ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" });
60
+ expect(run.run).toContain("chant agent run --forge github");
61
+ expect(run.run).toContain("trap chant_handover EXIT");
62
+ expect(Object.keys(run.env ?? {}).filter((n) => (FORGE_TOKEN_VARS as readonly string[]).includes(n))).toEqual([]);
63
+ expect(agent.steps[0]!.run).toContain("refs/pull/$PR_NUMBER/head:refs/chant/agent-head");
64
+ expect(agent.steps.some((s) => s.run?.includes("chant agent prepare"))).toBe(true);
65
+ });
66
+
67
+ test("the push job holds the write token, takes the change as data and lands it", () => {
68
+ expect(push.needs).toBe("chant-agent");
69
+ expect(push.permissions).toEqual({ contents: "write", "pull-requests": "write", issues: "write" });
70
+ const land = push.steps.at(-1)!;
71
+ expect(land.run).toContain("chant agent land --forge github");
72
+ expect(land.env?.CHANT_AGENT_TOKEN).toBe("${{ github.token }}");
73
+ expect(push.steps.some((s) => s.run?.includes("needs.chant-agent.outputs.report_0"))).toBe(true);
74
+ });
75
+
76
+ test("a push token secret replaces the job token, and the runner names where both jobs run", () => {
77
+ const { doc: d } = gen({ ...COMMENT, runner: ["self-hosted", "agents"], agent: { ...COMMENT.agent!, tokenEnv: "AGENT_PUSH_TOKEN" } });
78
+ expect(d.jobs["chant-agent"]!["runs-on"]).toEqual(["self-hosted", "agents"]);
79
+ expect(d.jobs["chant-agent-push"]!["runs-on"]).toEqual(["self-hosted", "agents"]);
80
+ expect(d.jobs["chant-agent-push"]!.steps.at(-1)!.env?.CHANT_AGENT_TOKEN).toBe("${{ secrets.AGENT_PUSH_TOKEN || github.token }}");
81
+ });
82
+
83
+ test("refuses settings no job could run", () => {
84
+ expect(() => gen({ name: "a", agent: { on: "comment" } })).toThrow(/comment/);
85
+ });
86
+ });
87
+
88
+ describe("the drift agent", () => {
89
+ const DRIFT: ScheduledOpSpec = { name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", agent: { on: "drift", maxRuns: 1 } };
90
+ const { doc } = gen(DRIFT);
91
+ const op = doc.jobs["drift-app"]!;
92
+ const agent = doc.jobs["drift-app-agent"]!;
93
+ const push = doc.jobs["drift-app-agent-push"]!;
94
+
95
+ test("the Op's job keeps its output and says when it found drift", () => {
96
+ const run = op.steps.find((s) => s.id === "chant-run")!;
97
+ expect(run.run).toContain('if [ "$code" -eq 2 ]; then echo "drift=true"');
98
+ expect(run.run).toContain("tee .chant/agent/drift.txt");
99
+ expect(op.outputs?.drift).toBe("${{ steps.chant-run.outputs.drift }}");
100
+ });
101
+
102
+ test("the agent job follows it on drift and takes its output", () => {
103
+ expect(agent.needs).toBe("drift-app");
104
+ expect(agent.if).toBe("always() && needs.drift-app.outputs.drift == 'true'");
105
+ expect(agent.steps.some((s) => s.run?.includes("needs.drift-app.outputs.report_0"))).toBe(true);
106
+ expect(agent.env?.CHANT_AGENT_BRANCH).toBe("chant/agent/drift-app");
107
+ });
108
+
109
+ test("the push job opens or updates the agent's pull request", () => {
110
+ expect(push.needs).toBe("drift-app-agent");
111
+ expect(push.steps.at(-1)!.name).toBe("Open or update the agent's pull request");
112
+ });
113
+
114
+ test("refuses a drift agent on a push trigger", () => {
115
+ expect(() => gen({ ...DRIFT, trigger: { kind: "push" } })).toThrow(/drift agent/);
116
+ });
117
+ });
118
+
119
+ describe("the runner field", () => {
120
+ test("names the Op job's runner, a group included", () => {
121
+ const { doc } = gen({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, runner: { group: "infra", labels: ["linux"] } });
122
+ expect(doc.jobs["drift-app"]!["runs-on"]).toEqual({ group: "infra", labels: ["linux"] });
123
+ expect(gen({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" } }).doc.jobs["drift-app"]!["runs-on"]).toBe("ubuntu-latest");
124
+ });
125
+ });
126
+
127
+ // #4619: a member's drift Op runs in the member's directory and hands its output on; the agent job takes it at the root.
128
+ describe("the drift agent of a workspace member", () => {
129
+ const out = generateGithubOpPipeline([{ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", agent: { on: "drift", maxCostUsd: 2.5 } }], { member: { name: "app", dir: "stacks/app" } });
130
+ const doc = parseYAML(out.files[0]!.yaml) as unknown as Doc & { jobs: Record<string, Job & { defaults?: { run?: { "working-directory"?: string } } }> };
131
+ const op = doc.jobs["drift-app"]!;
132
+ const agent = doc.jobs["drift-app-agent"]!;
133
+
134
+ test("the drift check runs in the member's directory and hands on the member's own output", () => {
135
+ expect(op.defaults?.run?.["working-directory"]).toBe("stacks/app");
136
+ expect(op.steps.find((s) => s.id === "chant-run")!.run).toContain("tee .chant/agent/drift.txt");
137
+ });
138
+
139
+ test("the agent job runs at the root and takes that output into its agent directory", () => {
140
+ expect(agent.defaults).toBeUndefined();
141
+ const take = agent.steps.find((s) => s.name === "Take the drift from the drift check")!;
142
+ expect(take.run).toContain("needs.drift-app.outputs.report_0");
143
+ expect(take.run).toContain(" .chant/agent <<");
144
+ expect(agent.steps.some((s) => s.run?.includes("chant agent prepare --forge github --output .chant/agent"))).toBe(true);
145
+ expect(agent.env?.CHANT_AGENT_MAX_COST_USD).toBe("2.5");
146
+ });
147
+ });
@@ -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
+ });
@@ -87,7 +87,8 @@ async function fakeForge(routes: Record<string, (body?: Record<string, unknown>)
87
87
  }
88
88
 
89
89
  async function runNotice(env: Record<string, string>): Promise<string> {
90
- const { stdout } = await run(process.execPath, ["--import", "tsx", "notice.mjs"], {
90
+ // chant here is the source tree, with no dist/: the development condition picks src/*.ts over the import condition's dist/*.js (#4591).
91
+ const { stdout } = await run(process.execPath, ["--conditions=development", "--import", "tsx", "notice.mjs"], {
91
92
  cwd: jobDir(),
92
93
  env: {
93
94
  PATH: process.env.PATH ?? "",