@intentius/chant-lexicon-gitlab 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.
- package/dist/components/agent-jobs.d.ts +68 -0
- package/dist/components/agent-jobs.d.ts.map +1 -0
- package/dist/components/agent-jobs.js +135 -0
- package/dist/components/control-pipeline.d.ts +24 -0
- package/dist/components/control-pipeline.d.ts.map +1 -0
- package/dist/components/control-pipeline.js +28 -0
- package/dist/components/generate-op-pipeline.d.ts +9 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +147 -23
- package/dist/components/generate-op-waves-pipeline.d.ts +25 -2
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +110 -12
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +135 -11
- package/dist/components/review-jobs.d.ts +72 -0
- package/dist/components/review-jobs.d.ts.map +1 -0
- package/dist/components/review-jobs.js +124 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +3 -3
- package/src/components/__golden__/op-waves-jobs.yml +11 -1
- package/src/components/agent-jobs.ts +176 -0
- package/src/components/control-pipeline.ts +38 -0
- package/src/components/generate-op-pipeline.agent.test.ts +99 -0
- package/src/components/generate-op-pipeline.cloud.test.ts +40 -0
- package/src/components/generate-op-pipeline.review.test.ts +72 -7
- package/src/components/generate-op-pipeline.ts +157 -24
- package/src/components/generate-op-waves-pipeline.approval.test.ts +28 -0
- package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +63 -0
- package/src/components/generate-op-waves-pipeline.test.ts +22 -3
- package/src/components/generate-op-waves-pipeline.ts +123 -12
- package/src/components/generate-pipeline.ts +143 -9
- package/src/components/generate-pr-pipeline.comment-locks.test.ts +43 -0
- package/src/components/generate-pr-pipeline.comment-preview.test.ts +61 -0
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +20 -0
- package/src/components/generate-pr-pipeline.control.test.ts +81 -0
- package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +46 -0
- package/src/components/generate-pr-pipeline.respond.test.ts +27 -0
- package/src/components/generate-pr-pipeline.runners.test.ts +41 -0
- package/src/components/generate-pr-pipeline.stale-notes.test.ts +24 -0
- package/src/components/review-jobs.ts +163 -0
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "601276a64d47a1758b2b6e26d232c5f746ec007b05950d993fc37ef4f09e2250",
|
|
5
5
|
"meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
|
|
6
6
|
"types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
|
|
7
7
|
"rules/artifact-no-expiry.ts": "0ca5bb972d4a542126e8a0229d43b55658394cb7dd9b9923b2c590bec1c2ad7d",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
|
|
56
56
|
"skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
|
|
57
57
|
},
|
|
58
|
-
"composite": "
|
|
58
|
+
"composite": "9ed920259d4f54b7734a8887fd98ea4e6357311cde69b7a84197365dc95505b5"
|
|
59
59
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gitlab",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.56",
|
|
4
4
|
"description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^5.9.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@intentius/chant": "^0.124.
|
|
71
|
-
"@intentius/chant-lexicon-github": "^0.124.
|
|
70
|
+
"@intentius/chant": "^0.124.56",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.124.56"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@intentius/chant-lexicon-github": {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# chant Op waves "migrations": one stage per wave, each needing the one before.
|
|
2
2
|
# A wave that waits at its gate exits 3 and stops the waves after it; approve the
|
|
3
3
|
# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed
|
|
4
|
-
# failure, and the waves after it run what does not depend on those runs.
|
|
4
|
+
# failure, and the waves after it run what does not depend on those runs. A job
|
|
5
|
+
# whose guard finds it running where the spec did not set it to run exits 9 and
|
|
6
|
+
# stops the waves after it.
|
|
5
7
|
|
|
6
8
|
stages:
|
|
7
9
|
- wave-1-dev
|
|
@@ -16,9 +18,11 @@ wave-1-dev:
|
|
|
16
18
|
resource_group: migrations-dev
|
|
17
19
|
variables:
|
|
18
20
|
GIT_DEPTH: '0'
|
|
21
|
+
CHANT_JOB_GUARD: '{"forge":"gitlab","job":"wave 1 (dev) of migrations","branches":["main"],"runnerTags":["docker","dev"]}'
|
|
19
22
|
rules:
|
|
20
23
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
21
24
|
script:
|
|
25
|
+
- '(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)));'')'
|
|
22
26
|
- chant run wave --spec waves.json --wave 1
|
|
23
27
|
allow_failure:
|
|
24
28
|
exit_codes:
|
|
@@ -40,11 +44,13 @@ wave-2-prod-decide:
|
|
|
40
44
|
aud: plan.example
|
|
41
45
|
variables:
|
|
42
46
|
GIT_DEPTH: '0'
|
|
47
|
+
CHANT_JOB_GUARD: '{"forge":"gitlab","job":"the deciding job of wave 2 (prod) of migrations","branches":["main"],"runnerTags":["plan"]}'
|
|
43
48
|
rules:
|
|
44
49
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
45
50
|
needs:
|
|
46
51
|
- wave-1-dev
|
|
47
52
|
script:
|
|
53
|
+
- '(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)));'')'
|
|
48
54
|
- chant run wave --spec waves.json --wave 2 --decide
|
|
49
55
|
allow_failure:
|
|
50
56
|
exit_codes:
|
|
@@ -70,11 +76,13 @@ wave-2-prod-us:
|
|
|
70
76
|
variables:
|
|
71
77
|
GIT_DEPTH: '0'
|
|
72
78
|
REGION: us
|
|
79
|
+
CHANT_JOB_GUARD: '{"forge":"gitlab","job":"job group us of wave 2 (prod) of migrations","forgeEnvironment":"prod-us","branches":["main"],"runnerTags":["us-apply"]}'
|
|
73
80
|
rules:
|
|
74
81
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
75
82
|
needs:
|
|
76
83
|
- wave-2-prod-decide
|
|
77
84
|
script:
|
|
85
|
+
- '(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)));'')'
|
|
78
86
|
- echo us
|
|
79
87
|
- chant run wave --spec waves.json --wave 2 --group us
|
|
80
88
|
allow_failure:
|
|
@@ -99,11 +107,13 @@ wave-2-prod-eu:
|
|
|
99
107
|
aud: eu.example
|
|
100
108
|
variables:
|
|
101
109
|
GIT_DEPTH: '0'
|
|
110
|
+
CHANT_JOB_GUARD: '{"forge":"gitlab","job":"job group eu of wave 2 (prod) of migrations","forgeEnvironment":"prod-eu","branches":["main"],"runnerTags":["eu-apply"]}'
|
|
102
111
|
rules:
|
|
103
112
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
|
104
113
|
needs:
|
|
105
114
|
- wave-2-prod-decide
|
|
106
115
|
script:
|
|
116
|
+
- '(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)));'')'
|
|
107
117
|
- chant run wave --spec waves.json --wave 2 --group eu
|
|
108
118
|
allow_failure:
|
|
109
119
|
exit_codes:
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent jobs on GitLab (#4616): `ScheduledOpSpec.agent`. What each job
|
|
3
|
+
* does is @intentius/chant/ci-agent's; this file lays them out in GitLab's
|
|
4
|
+
* terms.
|
|
5
|
+
*
|
|
6
|
+
* GitLab starts no pipeline on a note, and every job in a pipeline gets the
|
|
7
|
+
* same protected variables. So:
|
|
8
|
+
*
|
|
9
|
+
* - a comment agent runs in a pipeline of the default branch that the API, a
|
|
10
|
+
* trigger or a person starts with `CHANT_AGENT_PR` (the merge request's
|
|
11
|
+
* iid) and `CHANT_AGENT_ASK` (the ask). Whoever starts that pipeline is
|
|
12
|
+
* who asked. A drift agent follows the drift Op's job in its scheduled
|
|
13
|
+
* pipeline and runs when that job's run exited with chant's `drift` code.
|
|
14
|
+
* - the agent job unsets every forge token variable and drops the job token
|
|
15
|
+
* from the remote before the model runs, and keeps the agent directory as
|
|
16
|
+
* an artifact. It holds the model's key.
|
|
17
|
+
* - the push job runs in the same pipeline under environment
|
|
18
|
+
* `<agent>-push`, takes the artifact, and pushes with `tokenEnv`
|
|
19
|
+
* (`CHANT_FORGE_TOKEN` by default). Scope that variable to the
|
|
20
|
+
* `<agent>-push` environment, protected and masked, so the agent job never
|
|
21
|
+
* holds it.
|
|
22
|
+
*
|
|
23
|
+
* A workspace member's drift Op runs in the member's directory, so its
|
|
24
|
+
* artifact holds `<member>/.chant/agent` (#4619). Both agent jobs run at
|
|
25
|
+
* the repository root and take `dir`, that path, as their agent directory.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
29
|
+
import { gitlabTags, type CiRunner } from "@intentius/chant/ci-jobs";
|
|
30
|
+
import {
|
|
31
|
+
AGENT_DECISION_FILE,
|
|
32
|
+
AGENT_DIR,
|
|
33
|
+
AGENT_DRIFT_FILE,
|
|
34
|
+
AGENT_GITLAB_TOKEN_ENV,
|
|
35
|
+
AGENT_HEAD_REF,
|
|
36
|
+
agentCommandLines,
|
|
37
|
+
agentGitAuthLines,
|
|
38
|
+
agentHeadFetchLine,
|
|
39
|
+
agentSettingsEnv,
|
|
40
|
+
GITLAB_AGENT_ASK_VAR,
|
|
41
|
+
GITLAB_AGENT_PR_VAR,
|
|
42
|
+
type AgentSettings,
|
|
43
|
+
} from "@intentius/chant/ci-agent";
|
|
44
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
45
|
+
|
|
46
|
+
/** The dotenv the drift Op's job leaves, which says whether it found drift. */
|
|
47
|
+
const DRIFT_DOTENV = `${AGENT_DIR}/drift.env`;
|
|
48
|
+
const AGENT_ARTIFACT_EXPIRY = "7 days";
|
|
49
|
+
|
|
50
|
+
/** The comment agent's rule: a pipeline of the default branch started with the merge request and the ask. */
|
|
51
|
+
export const GITLAB_AGENT_RULE = `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger|chat)$/ && $${GITLAB_AGENT_PR_VAR} =~ /^[0-9]+$/ && $${GITLAB_AGENT_ASK_VAR} && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`;
|
|
52
|
+
|
|
53
|
+
export function gitlabAgentPushJobName(jobName: string): string {
|
|
54
|
+
return `${jobName}-push`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The environment the push job runs under, which the push token is scoped to. */
|
|
58
|
+
export function gitlabAgentEnvironment(jobName: string): string {
|
|
59
|
+
return `${jobName}-push`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The remote without the job token GitLab put in its URL. */
|
|
63
|
+
const PLAIN_REMOTE = 'git remote set-url origin "$CI_SERVER_URL/$CI_PROJECT_PATH.git"';
|
|
64
|
+
/** The ledger branch, for the run count and the record. */
|
|
65
|
+
const LEDGER_FETCH = "git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The drift Op's script lines (#4616): the run with its output kept for the
|
|
69
|
+
* agent, and `CHANT_AGENT_DRIFT=1` in a dotenv when it exits with chant's
|
|
70
|
+
* `drift` code. The job still exits with the run's code.
|
|
71
|
+
*/
|
|
72
|
+
export function gitlabDriftRunLines(invocation: string): string[] {
|
|
73
|
+
return [
|
|
74
|
+
`mkdir -p ${AGENT_DIR}`,
|
|
75
|
+
`set +e; { ${invocation}; echo "$?" > ${AGENT_DIR}/code; } 2>&1 | tee ${AGENT_DIR}/${AGENT_DRIFT_FILE}; code=$(cat ${AGENT_DIR}/code); set -e`,
|
|
76
|
+
`if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "CHANT_AGENT_DRIFT=1" > ${DRIFT_DOTENV}; else echo "CHANT_AGENT_DRIFT=0" > ${DRIFT_DOTENV}; fi`,
|
|
77
|
+
'exit "$code"',
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The drift Op job's artifacts: the output for the agent and the dotenv. */
|
|
82
|
+
export function gitlabDriftArtifacts(dir: string): Record<string, unknown> {
|
|
83
|
+
return { when: "always", paths: [dir], reports: { dotenv: `${dir}/drift.env` }, expire_in: AGENT_ARTIFACT_EXPIRY };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface GitlabAgentJobsInput {
|
|
87
|
+
jobName: string;
|
|
88
|
+
stage: string;
|
|
89
|
+
image: string;
|
|
90
|
+
settings: AgentSettings;
|
|
91
|
+
runner?: CiRunner;
|
|
92
|
+
/** Setup and before-script lines, run before chant in both jobs. */
|
|
93
|
+
script: readonly string[];
|
|
94
|
+
/** The variable holding the push token. Default {@link AGENT_GITLAB_TOKEN_ENV}. */
|
|
95
|
+
tokenEnv?: string;
|
|
96
|
+
bin?: string;
|
|
97
|
+
/** For a drift agent: the Op's job and its rules. */
|
|
98
|
+
after?: { job: string; rules: unknown[] };
|
|
99
|
+
/** The agent directory from the repository root: where the drift Op's artifact put the drift. Default {@link AGENT_DIR}. */
|
|
100
|
+
dir?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The agent job and the push job. */
|
|
104
|
+
export function gitlabAgentJobs(i: GitlabAgentJobsInput): { agent: Record<string, unknown>; push: Record<string, unknown> } {
|
|
105
|
+
const s = i.settings;
|
|
106
|
+
const dir = i.dir ?? AGENT_DIR;
|
|
107
|
+
const lines = agentCommandLines("gitlab", i.bin, dir);
|
|
108
|
+
const comment = s.on === "comment";
|
|
109
|
+
const tags = gitlabTags(i.runner);
|
|
110
|
+
const variables = { ...agentSettingsEnv(s), GIT_DEPTH: "0" };
|
|
111
|
+
const rules = comment ? [{ if: GITLAB_AGENT_RULE }] : i.after!.rules.map((r) => ({ ...(r as Record<string, unknown>), when: "always" }));
|
|
112
|
+
const head = comment ? [agentHeadFetchLine("gitlab", GITLAB_AGENT_PR_VAR)] : [];
|
|
113
|
+
// A drift agent's job runs whatever the drift check found, and stops here when it found none.
|
|
114
|
+
const driftGate = comment ? [] : [`if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the agent does not run"; exit 0; fi`];
|
|
115
|
+
const agent: Record<string, unknown> = {
|
|
116
|
+
stage: i.stage,
|
|
117
|
+
image: i.image,
|
|
118
|
+
...(tags ? { tags } : {}),
|
|
119
|
+
resource_group: i.jobName,
|
|
120
|
+
timeout: `${s.timeout + 15} minutes`,
|
|
121
|
+
variables,
|
|
122
|
+
rules,
|
|
123
|
+
...(comment ? {} : { needs: [{ job: i.after!.job, artifacts: true }] }),
|
|
124
|
+
script: [
|
|
125
|
+
...driftGate,
|
|
126
|
+
...head,
|
|
127
|
+
LEDGER_FETCH,
|
|
128
|
+
PLAIN_REMOTE,
|
|
129
|
+
...i.script,
|
|
130
|
+
// No forge token reaches the model: the decision needs none on GitLab, and the push job checks the merge request.
|
|
131
|
+
GITLAB_DROP_FORGE_TOKENS,
|
|
132
|
+
lines.prepare,
|
|
133
|
+
lines.run,
|
|
134
|
+
],
|
|
135
|
+
artifacts: { when: "always", paths: [dir], expire_in: AGENT_ARTIFACT_EXPIRY },
|
|
136
|
+
};
|
|
137
|
+
const tokenVar = i.tokenEnv ?? AGENT_GITLAB_TOKEN_ENV;
|
|
138
|
+
const push: Record<string, unknown> = {
|
|
139
|
+
stage: i.stage,
|
|
140
|
+
image: i.image,
|
|
141
|
+
...(tags ? { tags } : {}),
|
|
142
|
+
resource_group: gitlabAgentPushJobName(i.jobName),
|
|
143
|
+
environment: { name: gitlabAgentEnvironment(i.jobName), action: "access" },
|
|
144
|
+
variables,
|
|
145
|
+
rules,
|
|
146
|
+
needs: [{ job: i.jobName, artifacts: true }],
|
|
147
|
+
script: [
|
|
148
|
+
// The agent job leaves a decision whenever it ran; with none, the drift check found nothing.
|
|
149
|
+
...(comment ? [] : [`if [ ! -f ${dir}/${AGENT_DECISION_FILE} ]; then echo "the agent did not run"; exit 0; fi`]),
|
|
150
|
+
...head,
|
|
151
|
+
LEDGER_FETCH,
|
|
152
|
+
PLAIN_REMOTE,
|
|
153
|
+
...i.script,
|
|
154
|
+
`export CHANT_GITLAB_TOKEN="$${tokenVar}"`,
|
|
155
|
+
...agentGitAuthLines(tokenVar, "oauth2"),
|
|
156
|
+
lines.land,
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
return { agent, push };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The header lines a file with agent jobs carries: how to start a comment agent and where the token goes. */
|
|
163
|
+
export function gitlabAgentHeader(jobName: string, s: AgentSettings, tokenVar = AGENT_GITLAB_TOKEN_ENV): string[] {
|
|
164
|
+
return [
|
|
165
|
+
comment(s)
|
|
166
|
+
? `# ${jobName}: answers ${s.comment} from a pipeline of the default branch started with ${GITLAB_AGENT_PR_VAR}=<iid> and ${GITLAB_AGENT_ASK_VAR}=<ask>`
|
|
167
|
+
: `# ${jobName}: after the drift check exits ${CHANT_EXIT.drift}, opens or updates a merge request from ${s.branch}`,
|
|
168
|
+
`# ${gitlabAgentPushJobName(jobName)} pushes with ${tokenVar}: scope it to environment ${gitlabAgentEnvironment(jobName)}, protected and masked; the model's key is ${s.keyEnv}`,
|
|
169
|
+
`# the fetched head is ${AGENT_HEAD_REF}; runs are limited to ${s.maxRuns ?? "no fixed number of"} a day on the ledger` +
|
|
170
|
+
(s.maxCostUsd !== undefined ? `, and to $${s.maxCostUsd} a day of recorded cost` : ""),
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function comment(s: AgentSettings): boolean {
|
|
175
|
+
return s.on === "comment";
|
|
176
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control repo's own pipeline (#4568) on GitLab: a member's pipeline
|
|
3
|
+
* trigger starts it with `CHANT_CONTROL_MEMBER` and `CHANT_CONTROL_OP` set,
|
|
4
|
+
* and its one job runs the member's dependents with `chant workspace run
|
|
5
|
+
* --dependents-of`. The pieces are core's (`controlPipelineSpec` in
|
|
6
|
+
* `@intentius/chant/control/dispatch`); the file is one the control repo's
|
|
7
|
+
* `.gitlab-ci.yml` includes. The member's side, the `control-dispatch` job,
|
|
8
|
+
* comes from the pull request loop's `control` option
|
|
9
|
+
* (./generate-pipeline.ts).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { CI_IMAGE, GITLAB_FULL_HISTORY } from "@intentius/chant/ci-jobs";
|
|
13
|
+
import { controlPipelineSpec } from "@intentius/chant/control/dispatch";
|
|
14
|
+
import type { CiProductWords, CiProductWordsInput } from "@intentius/chant/ci-product-words";
|
|
15
|
+
import { emitGitlabFile } from "./ci-jobs";
|
|
16
|
+
|
|
17
|
+
/** A control repo's pipeline file: where it goes, its text, and what a person still sets up. */
|
|
18
|
+
export interface ControlPipelineFile {
|
|
19
|
+
path: string;
|
|
20
|
+
content: string;
|
|
21
|
+
notes: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The control repo's pipeline on GitLab: `.gitlab/<product>-dependents.yml`. */
|
|
25
|
+
export function generateControlPipeline(options: { words?: CiProductWordsInput | CiProductWords; image?: string; command?: string } = {}): ControlPipelineFile {
|
|
26
|
+
const spec = controlPipelineSpec("gitlab", options.words ?? {}, options.command ? { command: options.command } : {});
|
|
27
|
+
const job = {
|
|
28
|
+
stage: "deploy",
|
|
29
|
+
image: options.image ?? CI_IMAGE,
|
|
30
|
+
// The full history, so the job reads and appends to chant/lifecycle (#4617).
|
|
31
|
+
variables: { ...GITLAB_FULL_HISTORY },
|
|
32
|
+
rules: [{ if: spec.rule }],
|
|
33
|
+
// One member at a time: two applies upstream should not run the same dependents side by side.
|
|
34
|
+
resource_group: spec.group,
|
|
35
|
+
script: [`bash <<'DEPENDENTS' || exit $?\n${spec.script}\nDEPENDENTS`],
|
|
36
|
+
};
|
|
37
|
+
return { path: spec.path, content: emitGitlabFile([["dependents", job]], spec.header), notes: spec.notes };
|
|
38
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent jobs on GitLab (#4616): the comment agent in a pipeline of the
|
|
3
|
+
* default branch started with the merge request and the ask, the drift agent
|
|
4
|
+
* after a drift check, the push token scoped to the push job's environment,
|
|
5
|
+
* and a runner as tags.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
10
|
+
import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
11
|
+
import { generateGitlabOpPipeline } from "./generate-op-pipeline";
|
|
12
|
+
|
|
13
|
+
type Job = { rules?: Array<{ if?: string; when?: string }>; needs?: Array<{ job: string }>; tags?: string[]; timeout?: string; environment?: { name: string }; script: string[]; artifacts?: { paths?: string[]; reports?: Record<string, string> }; variables?: Record<string, string> };
|
|
14
|
+
const gen = (spec: ScheduledOpSpec) => {
|
|
15
|
+
const out = generateGitlabOpPipeline([spec]);
|
|
16
|
+
return { doc: parseYAML(out.files[0]!.yaml) as unknown as Record<string, Job>, yaml: out.files[0]!.yaml };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("the comment agent on GitLab", () => {
|
|
20
|
+
const { doc, yaml } = gen({ name: "chant-agent", runner: ["agents"], agent: { on: "comment", comment: "/chant agent", maxRuns: 4 } });
|
|
21
|
+
const agent = doc["chant-agent"]!;
|
|
22
|
+
const push = doc["chant-agent-push"]!;
|
|
23
|
+
|
|
24
|
+
test("runs in a pipeline of the default branch started with the merge request and the ask", () => {
|
|
25
|
+
expect(agent.rules?.[0]?.if).toContain("$CHANT_AGENT_PR =~ /^[0-9]+$/");
|
|
26
|
+
expect(agent.rules?.[0]?.if).toContain("$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH");
|
|
27
|
+
expect(agent.tags).toEqual(["agents"]);
|
|
28
|
+
expect(agent.timeout).toBe("45 minutes");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("the agent job drops every forge token before the decision and the model", () => {
|
|
32
|
+
const unset = agent.script.findIndex((l) => l.startsWith("unset "));
|
|
33
|
+
const prepare = agent.script.findIndex((l) => l.includes("chant agent prepare"));
|
|
34
|
+
expect(unset).toBeGreaterThan(-1);
|
|
35
|
+
expect(unset).toBeLessThan(prepare);
|
|
36
|
+
expect(agent.script.at(-1)).toContain("chant agent run --forge gitlab");
|
|
37
|
+
expect(agent.artifacts?.paths).toEqual([".chant/agent"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the push job runs under its own environment with the push token", () => {
|
|
41
|
+
expect(push.environment?.name).toBe("chant-agent-push");
|
|
42
|
+
expect(push.needs).toEqual([{ job: "chant-agent", artifacts: true }]);
|
|
43
|
+
expect(push.script.join("\n")).toContain('export CHANT_GITLAB_TOKEN="$CHANT_FORGE_TOKEN"');
|
|
44
|
+
expect(push.script.at(-1)).toContain("chant agent land --forge gitlab");
|
|
45
|
+
expect(yaml).toContain("scope it to environment chant-agent-push");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("the drift agent on GitLab", () => {
|
|
50
|
+
const { doc } = gen({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, agent: { on: "drift" } });
|
|
51
|
+
|
|
52
|
+
test("the drift check keeps its output and a dotenv that says it found drift", () => {
|
|
53
|
+
const op = doc["drift-app"]!;
|
|
54
|
+
expect(op.script.join("\n")).toContain('echo "CHANT_AGENT_DRIFT=1"');
|
|
55
|
+
expect(op.artifacts?.reports).toEqual({ dotenv: ".chant/agent/drift.env" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the agent and push jobs follow it whatever it exited, and stop when it found none", () => {
|
|
59
|
+
const agent = doc["drift-app-agent"]!;
|
|
60
|
+
expect(agent.needs).toEqual([{ job: "drift-app", artifacts: true }]);
|
|
61
|
+
expect(agent.rules?.every((r) => r.when === "always")).toBe(true);
|
|
62
|
+
expect(agent.script[0]).toContain('"${CHANT_AGENT_DRIFT:-0}" != "1"');
|
|
63
|
+
expect(doc["drift-app-agent-push"]!.script[0]).toContain("decision.json");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a runner group is refused", () => {
|
|
67
|
+
expect(() => gen({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, runner: { group: "infra" } })).toThrow(/runner group/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// #4619: a member's drift Op runs in the member's directory, and its artifact keeps the drift there.
|
|
72
|
+
describe("the drift agent of a workspace member on GitLab", () => {
|
|
73
|
+
const out = generateGitlabOpPipeline([{ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, agent: { on: "drift", maxCostUsd: 4 } }], { member: { name: "app", dir: "stacks/app" } });
|
|
74
|
+
const doc = parseYAML(out.files[0]!.yaml) as unknown as Record<string, Job>;
|
|
75
|
+
const op = doc["app-drift-app"]!;
|
|
76
|
+
const agent = doc["app-drift-app-agent"]!;
|
|
77
|
+
const push = doc["app-drift-app-agent-push"]!;
|
|
78
|
+
|
|
79
|
+
test("the drift check runs in the member's directory and keeps the drift under it", () => {
|
|
80
|
+
expect(op.script[0]).toBe("cd stacks/app");
|
|
81
|
+
expect(op.artifacts?.paths).toEqual(["stacks/app/.chant/agent"]);
|
|
82
|
+
expect(op.artifacts?.reports).toEqual({ dotenv: "stacks/app/.chant/agent/drift.env" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the agent and push jobs read the member's own drift output", () => {
|
|
86
|
+
expect(agent.script.some((l) => l.startsWith("cd "))).toBe(false);
|
|
87
|
+
expect(agent.script.find((l) => l.includes("agent prepare"))).toBe("chant agent prepare --forge gitlab --output stacks/app/.chant/agent");
|
|
88
|
+
expect(agent.script.at(-1)).toBe("chant agent run --forge gitlab --output stacks/app/.chant/agent");
|
|
89
|
+
expect(agent.artifacts?.paths).toEqual(["stacks/app/.chant/agent"]);
|
|
90
|
+
expect(push.script[0]).toContain("stacks/app/.chant/agent/decision.json");
|
|
91
|
+
expect(push.script.at(-1)).toBe("chant agent land --forge gitlab --output stacks/app/.chant/agent");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the money limit reaches both jobs and the file's header", () => {
|
|
95
|
+
expect(agent.variables?.CHANT_AGENT_MAX_COST_USD).toBe("4");
|
|
96
|
+
expect(push.variables?.CHANT_AGENT_MAX_COST_USD).toBe("4");
|
|
97
|
+
expect(out.files[0]!.yaml).toContain("and to $4 a day of recorded cost");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** #4631: a GitLab scheduled Op job trades first, and an update on drift is a job of its own on the run's dotenv. */
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
4
|
+
import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
5
|
+
import { generateGitlabOpPipeline } from "./generate-op-pipeline";
|
|
6
|
+
|
|
7
|
+
interface Job { needs?: unknown; rules?: Array<Record<string, unknown>>; id_tokens?: Record<string, { aud: string }>; variables?: Record<string, string>; script: string[]; artifacts?: Record<string, unknown> }
|
|
8
|
+
|
|
9
|
+
const PREVIEW = { aws: { role: "arn:aws:iam::1:role/preview", region: "us-east-1", audience: "sts.amazonaws.com" } };
|
|
10
|
+
const UPDATE = { aws: { role: "arn:aws:iam::1:role/update", region: "us-east-1", audience: "sts.amazonaws.com" } };
|
|
11
|
+
const spec: ScheduledOpSpec = { name: "drift-dev", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", cloud: PREVIEW, update: { command: "plywood up", cloud: UPDATE } };
|
|
12
|
+
|
|
13
|
+
describe("cloud identities on a GitLab scheduled Op (#4631)", () => {
|
|
14
|
+
const doc = parseYAML(generateGitlabOpPipeline([spec]).files[0]!.yaml) as unknown as Record<string, Job>;
|
|
15
|
+
const check = doc["drift-dev"]!;
|
|
16
|
+
const update = doc["drift-dev-update"]!;
|
|
17
|
+
|
|
18
|
+
test("the check's script trades first and unsets the token variables, with an id token per cloud", () => {
|
|
19
|
+
expect(check.id_tokens).toEqual({ CHANT_ID_TOKEN_AWS: { aud: "sts.amazonaws.com" } });
|
|
20
|
+
expect(JSON.parse(check.variables!.CHANT_CLOUD_TRADE!)).toEqual(PREVIEW);
|
|
21
|
+
expect(check.script[0]).toContain("node -e");
|
|
22
|
+
expect(check.script[1]).toMatch(/^unset .*CHANT_ID_TOKEN_AWS/);
|
|
23
|
+
expect(check.script.join("\n")).toContain('echo "CHANT_AGENT_DRIFT=1"');
|
|
24
|
+
expect(check.artifacts).toMatchObject({ reports: { dotenv: ".chant/agent/drift.env" } });
|
|
25
|
+
expect(JSON.stringify(check)).not.toContain("role/update");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("the update job needs the check, stops with no drift, and trades for its own identity", () => {
|
|
29
|
+
expect(update.needs).toEqual([{ job: "drift-dev", artifacts: true }]);
|
|
30
|
+
expect(update.rules!.every((r) => r.when === "always")).toBe(true);
|
|
31
|
+
expect(update.script[0]).toContain('"${CHANT_AGENT_DRIFT:-0}" != "1"');
|
|
32
|
+
expect(JSON.parse(update.variables!.CHANT_CLOUD_TRADE!)).toEqual(UPDATE);
|
|
33
|
+
expect(update.script.at(-1)).toBe("plywood up");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("update.permissions has no GitLab form", () => {
|
|
37
|
+
expect(() => generateGitlabOpPipeline([{ ...spec, update: { command: "x", permissions: { issues: "write" } } }])).toThrow(/no GitLab form/);
|
|
38
|
+
expect(() => generateGitlabOpPipeline([{ ...spec, update: { command: "x", permissions: { contents: "write" } } }])).toThrow(/no GitLab form/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -1,21 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `reviewAgent` on GitLab (#
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* `reviewAgent` on GitLab (#4544): the review job runs in the merge request
|
|
3
|
+
* pipeline with no forge token and keeps its review as an artifact; the note
|
|
4
|
+
* job runs in a pipeline of the default branch, checks nothing out, and posts
|
|
5
|
+
* with the protected token.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
|
|
10
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
11
|
+
import type { ComponentPipelineOptions, ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
8
12
|
import { generateGitlabOpPipeline } from "./generate-op-pipeline";
|
|
9
13
|
|
|
14
|
+
const spec: ScheduledOpSpec = { name: "pr-review", trigger: { kind: "pull_request" }, reviewAgent: true };
|
|
15
|
+
|
|
16
|
+
function generated(specs: ScheduledOpSpec[] = [spec], options: ComponentPipelineOptions = {}) {
|
|
17
|
+
const out = generateGitlabOpPipeline(specs, options);
|
|
18
|
+
return { out, doc: parseYAML(out.files[0].yaml) as Record<string, Record<string, unknown>> };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ruleText = (job: Record<string, unknown>): string => JSON.stringify(job.rules);
|
|
22
|
+
const inMergeRequestPipeline = (job: Record<string, unknown>): boolean => ruleText(job).includes("merge_request_event");
|
|
23
|
+
|
|
10
24
|
describe("generateGitlabOpPipeline: reviewAgent", () => {
|
|
11
|
-
test("
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
);
|
|
25
|
+
test("emits the review job and its note job", () => {
|
|
26
|
+
const { out, doc } = generated();
|
|
27
|
+
expect(out.jobs.map((j) => j.jobName)).toEqual(["pr-review"]);
|
|
28
|
+
expect(Object.keys(doc)).toEqual(["stages", "pr-review", "pr-review-note"]);
|
|
29
|
+
expect(out.files[0].yaml).toContain('CHANT_REVIEW_NOTES="1"');
|
|
30
|
+
expect(out.files[0].yaml).toContain("protected, masked CI/CD variable");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the review job runs in the merge request pipeline, drops every forge token first and keeps the review as an artifact", () => {
|
|
34
|
+
const { doc } = generated();
|
|
35
|
+
const review = doc["pr-review"];
|
|
36
|
+
expect(inMergeRequestPipeline(review)).toBe(true);
|
|
37
|
+
const script = review.script as string[];
|
|
38
|
+
expect(script[0]).toBe(`unset ${FORGE_TOKEN_VARS.join(" ")}`);
|
|
39
|
+
expect(script).toContain("chant run pr-review");
|
|
40
|
+
expect(review.variables).toEqual({ CHANT_REVIEW_DIR: ".chant/review" });
|
|
41
|
+
expect(review.artifacts).toEqual({ paths: [".chant/review"], expire_in: "7 days" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no job of the merge request pipeline holds a write token", () => {
|
|
45
|
+
const { doc } = generated([spec, { name: "lint", trigger: { kind: "pull_request" } }], { variables: { FOO: "bar" } });
|
|
46
|
+
const writeTokens = ["CHANT_FORGE_TOKEN", "CHANT_RUNNER_KEY", "GITLAB_TOKEN", "CHANT_GITLAB_TOKEN"];
|
|
47
|
+
for (const [name, job] of Object.entries(doc)) {
|
|
48
|
+
if (name === "stages" || name === "variables" || !inMergeRequestPipeline(job)) continue;
|
|
49
|
+
const text = JSON.stringify({ variables: job.variables ?? {}, script: (job.script as string[]).filter((l) => !l.startsWith("unset ")) });
|
|
50
|
+
for (const token of writeTokens) expect(text, `${name} ${token}`).not.toContain(token);
|
|
51
|
+
}
|
|
52
|
+
expect(Object.keys(doc.variables ?? {})).toEqual(["FOO"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the note job runs only in a pipeline of the default branch, checks nothing out and treats the artifact as data", () => {
|
|
56
|
+
const { doc } = generated([{ ...spec, trigger: { kind: "pull_request", branches: ["main", "release"] } }]);
|
|
57
|
+
const note = doc["pr-review-note"];
|
|
58
|
+
expect(inMergeRequestPipeline(note)).toBe(false);
|
|
59
|
+
expect(note.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_REVIEW_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
|
|
60
|
+
expect(note.variables).toEqual({ GIT_STRATEGY: "none" });
|
|
61
|
+
const script = (note.script as string[]).join("\n");
|
|
62
|
+
expect(script).toMatch(/npm install .*@intentius\/chant@\d+\.\d+\.\d+/);
|
|
63
|
+
expect(script).toContain("postOpenReviewNotes");
|
|
64
|
+
expect(script).toContain('const branches = ["main","release"]');
|
|
65
|
+
expect(script).toContain('job: "pr-review", artifactDir: ".chant/review", op: "pr-review"');
|
|
66
|
+
expect(script).not.toContain("chant run");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a workspace member's review keeps its artifact under the member's directory", () => {
|
|
70
|
+
const { doc } = generated([spec], { member: { name: "web", dir: "apps/web" } });
|
|
71
|
+
expect(doc["web-pr-review"].artifacts).toEqual({ paths: ["apps/web/.chant/review"], expire_in: "7 days" });
|
|
72
|
+
expect((doc["web-pr-review-note"].script as string[]).join("\n")).toContain('job: "web-pr-review", artifactDir: "apps/web/.chant/review"');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("is refused on a trigger with no merge request, a posting finding-mode, or a write scope", () => {
|
|
76
|
+
expect(() => generateGitlabOpPipeline([{ ...spec, trigger: { kind: "push" } }])).toThrow(/sets reviewAgent.*trigger is "push"/s);
|
|
77
|
+
expect(() => generateGitlabOpPipeline([{ ...spec, findingMode: "comment" }])).toThrow(/findingMode "comment".*must be "report"/s);
|
|
78
|
+
expect(() => generateGitlabOpPipeline([{ ...spec, permissions: { "pull-requests": "write" } }])).toThrow(/"pull-requests: write"/);
|
|
15
79
|
});
|
|
16
80
|
|
|
17
81
|
test("an Op without it generates as before", () => {
|
|
18
82
|
const out = generateGitlabOpPipeline([{ name: "pr-review", trigger: { kind: "pull_request" }, findingMode: "comment" }]);
|
|
19
83
|
expect(out.jobs.map((j) => j.jobName)).toEqual(["pr-review"]);
|
|
84
|
+
expect(out.files[0].yaml).not.toContain("CHANT_REVIEW_NOTES");
|
|
20
85
|
});
|
|
21
86
|
});
|