@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.
- package/dist/components/generate-op-pipeline.d.ts +2 -2
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +73 -8
- package/dist/components/generate-op-waves-pipeline.d.ts +16 -3
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +82 -17
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +34 -6
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/__golden__/op-waves-jobs.yml +31 -4
- package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
- package/src/components/generate-op-pipeline.ts +89 -15
- package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
- package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
- package/src/components/generate-op-waves-pipeline.test.ts +19 -3
- package/src/components/generate-op-waves-pipeline.ts +85 -15
- package/src/components/generate-pipeline.ts +38 -6
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
- package/src/components/generate-pr-pipeline.locks.test.ts +10 -0
- package/src/components/generate-pr-pipeline.respond.test.ts +47 -0
- package/src/components/generate-pr-pipeline.runners.test.ts +61 -0
- 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
|
|
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
|
|
507
|
-
|
|
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
|
-
:
|
|
519
|
+
: update
|
|
520
|
+
? driftFlagStep(invocation, stepEnv)
|
|
521
|
+
: { run: invocation, env: stepEnv },
|
|
513
522
|
);
|
|
514
|
-
|
|
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:
|
|
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 })
|
|
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[
|
|
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[
|
|
74
|
-
expect(decide.steps[
|
|
75
|
-
expect(decide.steps[
|
|
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[
|
|
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(
|
|
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[
|
|
128
|
-
await run("sh", ["-c", decide.steps[
|
|
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
|
+
});
|