@intentius/chant-lexicon-forgejo 0.124.55 → 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.
@@ -12,8 +12,8 @@
12
12
  * so it dispatches the waves workflow again on its branch, with the
13
13
  * `CHANT_FORGE_TOKEN` secret (a token with the `write:repository` scope).
14
14
  *
15
- * A `pr-review` wave (#3684) gets `<name>-plans.yml` on pull requests, as on
16
- * GitHub. Forgejo's job token reads reviews with no permission to ask for.
15
+ * A `pr-review` wave (#3684), or a `pull-request` wave (#4630), gets
16
+ * `<name>-plans.yml` on pull requests, as on GitHub. Forgejo's job token reads reviews with no permission to ask for.
17
17
  *
18
18
  * `runner` and a wave's `jobs` (#3914) render as on GitHub, with two limits:
19
19
  * a runner is a label or a list of labels (a GitHub runner group is a spec
@@ -21,6 +21,10 @@
21
21
  * none to put in front of it. A job with id tokens keeps `permissions:
22
22
  * id-token: write`, the one scope the dialect leaves, and reads each audience
23
23
  * from `<NAME>_AUDIENCE` when it requests its token.
24
+ *
25
+ * The apply-job guard (#4624) starts each wave job as on GitHub, with the
26
+ * forge set to Forgejo: it checks the ref and, for a `self-hosted` label,
27
+ * the runner, and no forge environment, since Forgejo has none.
24
28
  */
25
29
  import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
26
30
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAIH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAEhE,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAGxD,6EAA6E;AAC7E,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,EAC/B,cAAc,GAAE,qBAA0B,GACzC,qBAAqB,CAiBvB"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAEhE,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAGxD,6EAA6E;AAC7E,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,EAC/B,cAAc,GAAE,qBAA0B,GACzC,qBAAqB,CAiBvB"}
@@ -12,8 +12,8 @@
12
12
  * so it dispatches the waves workflow again on its branch, with the
13
13
  * `CHANT_FORGE_TOKEN` secret (a token with the `write:repository` scope).
14
14
  *
15
- * A `pr-review` wave (#3684) gets `<name>-plans.yml` on pull requests, as on
16
- * GitHub. Forgejo's job token reads reviews with no permission to ask for.
15
+ * A `pr-review` wave (#3684), or a `pull-request` wave (#4630), gets
16
+ * `<name>-plans.yml` on pull requests, as on GitHub. Forgejo's job token reads reviews with no permission to ask for.
17
17
  *
18
18
  * `runner` and a wave's `jobs` (#3914) render as on GitHub, with two limits:
19
19
  * a runner is a label or a list of labels (a GitHub runner group is a spec
@@ -21,6 +21,10 @@
21
21
  * none to put in front of it. A job with id tokens keeps `permissions:
22
22
  * id-token: write`, the one scope the dialect leaves, and reads each audience
23
23
  * from `<NAME>_AUDIENCE` when it requests its token.
24
+ *
25
+ * The apply-job guard (#4624) starts each wave job as on GitHub, with the
26
+ * forge set to Forgejo: it checks the ref and, for a `self-hosted` label,
27
+ * the runner, and no forge environment, since Forgejo has none.
24
28
  */
25
29
  import { buildGithubOpWavesDoc } from "@intentius/chant-lexicon-github/components/generate-op-waves-pipeline";
26
30
  import { emitOpPipelineYAML } from "@intentius/chant-lexicon-github/components/generate-op-pipeline";
@@ -29,7 +33,7 @@ import { droppedWaveEnvironmentsHeader, FORGE_TOKEN_ENV, forgejoWorkflowDoc } fr
29
33
  /** Render an Op waves spec as one Forgejo Actions workflow, `<name>.yml`. */
30
34
  export function generateForgejoOpWavesPipeline(spec, options, dialectOptions = {}) {
31
35
  assertForgejoWaves(spec);
32
- const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options);
36
+ const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options, { forge: "forgejo" });
33
37
  const dropped = spec.waves.filter((w) => w.environment).map((w) => `${w.name} -> ${w.environment.name}`);
34
38
  const header = dropped.length > 0 ? droppedWaveEnvironmentsHeader(dropped) : undefined;
35
39
  const files = [{ name: `${spec.name}.yml`, yaml: emitOpPipelineYAML(forgejoWorkflowDoc(doc, dialectOptions, header)) }];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "18127222bb3fd237d748da8a38aafe6888852b53cdf62b7cfebb4918a040905e",
4
+ "manifest.json": "d8927356783df4ae21c431e0a016299273a031fecc05ead9df07f900b57ed4c7",
5
5
  "meta.json": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
6
6
  "types/index.d.ts": "66204549b2a864ab5489e3f02c1878e73ab4b3c9d938cfb480b66f71543a43de",
7
7
  "rules/delegate-to-github.ts": "1060cda40f4b73d6cca3ba3fa13b80b4886147d4034c88584c2d80c5510dbe5e",
@@ -9,5 +9,5 @@
9
9
  "rules/wfj011.ts": "fcb8bf6685d744af20ad804ce929488257995c74b9a3353b2eacae3768d1c83e",
10
10
  "skills/chant-forgejo.md": "f28d7fb69fb1cfd992496ef41fda0ea5ccd54a29b84ea1c501a44f6a3992c4d5"
11
11
  },
12
- "composite": "366563b3a7c31fc2bfa18468fe4382a732acf7f8ba302197ff1e1fe61910367d"
12
+ "composite": "7b0449ca0d3675a7e8c15a30fc85f3de807f6eb7eb538536867e77edd16ee133"
13
13
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgejo",
3
- "version": "0.124.55",
3
+ "version": "0.124.56",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Forgejo",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-forgejo",
3
- "version": "0.124.55",
3
+ "version": "0.124.56",
4
4
  "description": "Forgejo / Codeberg / Gitea Actions lexicon for chant — a thin GitHub Actions dialect",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -54,8 +54,8 @@
54
54
  },
55
55
  "peerDependencies": {
56
56
  "zod": "^4.3.6",
57
- "@intentius/chant": "^0.124.55",
58
- "@intentius/chant-lexicon-github": "^0.124.55"
57
+ "@intentius/chant": "^0.124.56",
58
+ "@intentius/chant-lexicon-github": "^0.124.56"
59
59
  },
60
60
  "scripts": {
61
61
  "generate": "tsx src/codegen/generate-cli.ts",
@@ -15,6 +15,10 @@ jobs:
15
15
  runs-on: plan
16
16
  container: node:22
17
17
  steps:
18
+ - name: Check the job runs where the spec set it to run
19
+ env:
20
+ CHANT_JOB_GUARD: '{"forge":"forgejo","job":"wave 1 (dev) of migrations","branches":["main"]}'
21
+ 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)));'')'
18
22
  - uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
19
23
  with:
20
24
  fetch-depth: 0
@@ -34,6 +38,10 @@ jobs:
34
38
  if: '!cancelled()'
35
39
  container: node:22
36
40
  steps:
41
+ - name: Check the job runs where the spec set it to run
42
+ env:
43
+ CHANT_JOB_GUARD: '{"forge":"forgejo","job":"the deciding job of wave 2 (prod) of migrations","branches":["main"]}'
44
+ 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)));'')'
37
45
  - uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
38
46
  with:
39
47
  fetch-depth: 0
@@ -70,6 +78,10 @@ jobs:
70
78
  REGION: us
71
79
  CLOUD_TOKEN_AUDIENCE: us.example
72
80
  steps:
81
+ - name: Check the job runs where the spec set it to run
82
+ env:
83
+ CHANT_JOB_GUARD: '{"forge":"forgejo","job":"job group us of wave 2 (prod) of migrations","branches":["main"],"selfHosted":true}'
84
+ 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)));'')'
73
85
  - uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
74
86
  with:
75
87
  fetch-depth: 0
@@ -97,6 +109,10 @@ jobs:
97
109
  - wave-2-prod-decide
98
110
  container: node:22
99
111
  steps:
112
+ - name: Check the job runs where the spec set it to run
113
+ env:
114
+ CHANT_JOB_GUARD: '{"forge":"forgejo","job":"job group eu of wave 2 (prod) of migrations","branches":["main"]}'
115
+ 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)));'')'
100
116
  - uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
101
117
  with:
102
118
  fetch-depth: 0
@@ -0,0 +1,25 @@
1
+ /** #4631: the Forgejo scheduled Op job trades for its cloud identities first, and an update on drift runs in its own job. */
2
+ import { describe, expect, test } from "vitest";
3
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
4
+ import { generateForgejoOpPipeline } from "./generate-op-pipeline";
5
+
6
+ const PREVIEW = { aws: { role: "arn:aws:iam::1:role/preview", region: "us-east-1", audience: "sts.amazonaws.com" } };
7
+ const UPDATE = { aws: { role: "arn:aws:iam::1:role/update", region: "us-east-1", audience: "sts.amazonaws.com" } };
8
+
9
+ describe("cloud identities on a Forgejo scheduled Op (#4631)", () => {
10
+ test("the trade, id-token: write on the job, and the update job", () => {
11
+ const spec: ScheduledOpSpec = { name: "drift-dev", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", cloud: PREVIEW, update: { command: "plywood up", cloud: UPDATE } };
12
+ const yaml = generateForgejoOpPipeline([spec]).files[0]!.yaml;
13
+ expect(yaml).toContain("Trade the job's OIDC token for its cloud identities");
14
+ expect(yaml).toContain("id-token: write");
15
+ expect(yaml).toContain("drift-dev-update:");
16
+ expect(yaml).toContain("needs.drift-dev.outputs.drift == 'true'");
17
+ expect(yaml).toContain("role/update");
18
+ });
19
+
20
+ test("an update with contents: write renders, and the refusal of contents: read holds (2026-10-28 ruling)", () => {
21
+ const spec: ScheduledOpSpec = { name: "drift-dev", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", cloud: PREVIEW, update: { command: "plywood up", cloud: UPDATE, permissions: { contents: "write" } } };
22
+ expect(generateForgejoOpPipeline([spec]).files[0]!.yaml).toContain("drift-dev-update:");
23
+ expect(() => generateForgejoOpPipeline([{ ...spec, update: { command: "plywood up", permissions: { contents: "read" } } }])).toThrow(/contents may only be raised to write/);
24
+ });
25
+ });
@@ -0,0 +1,26 @@
1
+ /** #4630: the Forgejo plans workflow posts the approval status with `--forge forgejo`. */
2
+ import { describe, expect, test } from "vitest";
3
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
4
+ import { generateForgejoOpWavesPipeline } from "./generate-op-waves-pipeline";
5
+
6
+ const spec: OpWavesSpec = {
7
+ name: "plywood-up",
8
+ op: "plywood-up",
9
+ plan: ["./bin/plan", "{target}", "{plan}"],
10
+ waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pr-review" }],
11
+ };
12
+
13
+ describe("the approval status job on Forgejo (#4630)", () => {
14
+ test("runs on reviews and after the plans with --forge forgejo", () => {
15
+ const plans = generateForgejoOpWavesPipeline(spec, { specFile: "waves.json" }).files.find((f) => f.name === "plywood-up-plans.yml")!.yaml;
16
+ expect(plans).toContain("pull_request_review:");
17
+ expect(plans).toContain('run wave --spec waves.json --approval-status --pr "$PR_NUMBER" --head "$HEAD_SHA" --base "$BASE_SHA" --forge forgejo');
18
+ });
19
+
20
+ test("a pull-request wave gets the plans workflow too (2026-10-28 ruling)", () => {
21
+ const pullRequest: OpWavesSpec = { ...spec, waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pull-request" }], approvals: { pullRequest: { gate: "up" } } };
22
+ const plans = generateForgejoOpWavesPipeline(pullRequest, { specFile: "waves.json" }).files.find((f) => f.name === "plywood-up-plans.yml")!.yaml;
23
+ expect(plans).toContain("run wave --spec waves.json --record-plans");
24
+ expect(plans).toContain("--approval-status --pr");
25
+ });
26
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The apply-job guard in Forgejo op-wave jobs (#4624): the GitHub step with
3
+ * the forge set to Forgejo, which checks the ref and runner and no
4
+ * environment. Written for the ci-products post-merge pass.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
9
+ import { JOB_GUARD_ENV } from "@intentius/chant/ci-job-guard";
10
+ import { generateForgejoOpWavesPipeline } from "./generate-op-waves-pipeline";
11
+
12
+ const spec: OpWavesSpec = {
13
+ name: "deploy",
14
+ op: "pulumi-up",
15
+ plan: ["./bin/plan", "{target}", "{plan}"],
16
+ runner: ["self-hosted", "docker"],
17
+ waves: [{ name: "prod", runs: [{ target: "prod" }], environment: { name: "production" }, idTokens: { CLOUD_TOKEN: "aud" } }],
18
+ };
19
+
20
+ describe("the apply-job guard on Forgejo (#4624)", () => {
21
+ test("the first step of the wave job, with the forge set to Forgejo and no environment", () => {
22
+ const yaml = generateForgejoOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
23
+ const steps = yaml.slice(yaml.indexOf("steps:"));
24
+ expect(steps.indexOf("Check the job runs where the spec set it to run")).toBeLessThan(steps.indexOf("checkout"));
25
+ expect(yaml).toContain(JOB_GUARD_ENV);
26
+ const json = /CHANT_JOB_GUARD: '(\{.*?\})'/.exec(yaml)?.[1] ?? /CHANT_JOB_GUARD: "(.*?)"\n/.exec(yaml)?.[1]?.replace(/\\"/g, '"');
27
+ expect(json).toBeDefined();
28
+ const guard = JSON.parse(json!) as Record<string, unknown>;
29
+ expect(guard).toEqual({ forge: "forgejo", job: "wave 1 (prod) of deploy", branches: ["main"], selfHosted: true });
30
+ });
31
+ });
@@ -115,8 +115,9 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
115
115
  expect(Object.keys(jobs)).toEqual(["wave-1-prod-decide", "wave-1-prod-a", "wave-1-prod-default"]);
116
116
  for (const job of Object.values(jobs)) {
117
117
  expect(job.permissions).toEqual({ "id-token": "write" });
118
- expect(job.steps[0]!.env![TRADE_SPEC_ENV]).toBeDefined();
119
- expect(job.steps[1]!.uses).toContain("checkout");
118
+ // #4624: the apply-job guard comes first, then the trade, then the checkout.
119
+ expect(job.steps[1]!.env![TRADE_SPEC_ENV]).toBeDefined();
120
+ expect(job.steps[2]!.uses).toContain("checkout");
120
121
  }
121
122
  });
122
123
 
@@ -12,8 +12,8 @@
12
12
  * so it dispatches the waves workflow again on its branch, with the
13
13
  * `CHANT_FORGE_TOKEN` secret (a token with the `write:repository` scope).
14
14
  *
15
- * A `pr-review` wave (#3684) gets `<name>-plans.yml` on pull requests, as on
16
- * GitHub. Forgejo's job token reads reviews with no permission to ask for.
15
+ * A `pr-review` wave (#3684), or a `pull-request` wave (#4630), gets
16
+ * `<name>-plans.yml` on pull requests, as on GitHub. Forgejo's job token reads reviews with no permission to ask for.
17
17
  *
18
18
  * `runner` and a wave's `jobs` (#3914) render as on GitHub, with two limits:
19
19
  * a runner is a label or a list of labels (a GitHub runner group is a spec
@@ -21,6 +21,10 @@
21
21
  * none to put in front of it. A job with id tokens keeps `permissions:
22
22
  * id-token: write`, the one scope the dialect leaves, and reads each audience
23
23
  * from `<NAME>_AUDIENCE` when it requests its token.
24
+ *
25
+ * The apply-job guard (#4624) starts each wave job as on GitHub, with the
26
+ * forge set to Forgejo: it checks the ref and, for a `self-hosted` label,
27
+ * the runner, and no forge environment, since Forgejo has none.
24
28
  */
25
29
 
26
30
  import { buildGithubOpWavesDoc } from "@intentius/chant-lexicon-github/components/generate-op-waves-pipeline";
@@ -38,7 +42,7 @@ export function generateForgejoOpWavesPipeline(
38
42
  dialectOptions: ForgejoDialectOptions = {},
39
43
  ): OpWavesPipelineResult {
40
44
  assertForgejoWaves(spec);
41
- const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options);
45
+ const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options, { forge: "forgejo" });
42
46
  const dropped = spec.waves.filter((w) => w.environment).map((w) => `${w.name} -> ${w.environment!.name}`);
43
47
  const header = dropped.length > 0 ? droppedWaveEnvironmentsHeader(dropped) : undefined;
44
48
  const files = [{ name: `${spec.name}.yml`, yaml: emitOpPipelineYAML(forgejoWorkflowDoc(doc, dialectOptions, header)) }];
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The preview comment's --target on Forgejo (#4626): the GitHub generator's
3
+ * preview step, with the comment body in CHANT_PREVIEW_COMMENT. Written for
4
+ * the ci-products post-merge pass.
5
+ */
6
+ import { expect, test } from "vitest";
7
+ import { parseYAML } from "@intentius/chant/yaml";
8
+ import type { DriverComponent } from "@intentius/chant/components/driver";
9
+ import { generateForgejoPipeline } from "./generate-pipeline";
10
+
11
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
12
+ const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const } };
13
+
14
+ test("the preview step reads the comment from a variable (#4626)", () => {
15
+ const doc = parseYAML(generateForgejoPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as { jobs: Record<string, { steps: Array<{ run?: string; env?: Record<string, string> }> }> };
16
+ const step = doc.jobs["comment-preview"]!.steps.find((s) => s.run?.includes("--comment-targets"))!;
17
+ expect(step.env!.CHANT_PREVIEW_COMMENT).toBe("${{ github.event.comment.body }}");
18
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The pull request loop's runners and passed secrets on Forgejo (#4625):
3
+ * the GitHub generator's, through the dialect. Written for the ci-products
4
+ * post-merge pass.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ import type { DriverComponent } from "@intentius/chant/components/driver";
10
+ import { generateForgejoPipeline } from "./generate-pipeline";
11
+
12
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
13
+
14
+ type Doc = { jobs: Record<string, { "runs-on": unknown; steps: Array<{ name?: string; env?: Record<string, string> }> }> };
15
+
16
+ describe("the Forgejo pull request loop's runners and secrets (#4625)", () => {
17
+ test("the plan runs on its stage's runner with the passed secret", () => {
18
+ const doc = parseYAML(generateForgejoPipeline(ESTATE, { env: "prod", prLoop: { runners: { plan: "big", default: "small" }, secrets: { DB_PASSWORD: "PROD_DB" } } }).yaml) as Doc;
19
+ expect(doc.jobs.plan!["runs-on"]).toBe("big");
20
+ expect(doc.jobs["plan-note"]!["runs-on"]).toBe("small");
21
+ const step = doc.jobs.plan!.steps.find((s) => s.name === "Plan the members this pull request reaches")!;
22
+ expect(step.env).toMatchObject({ DB_PASSWORD: "${{ secrets.PROD_DB }}" });
23
+ });
24
+ });
@@ -0,0 +1,12 @@
1
+ /** Stale marking on Forgejo (#4628): the GitHub generator's stale-notes job, posting on forgejo. Written for the ci-products post-merge pass. */
2
+ import { expect, test } from "vitest";
3
+ import { parseYAML } from "@intentius/chant/yaml";
4
+ import type { DriverComponent } from "@intentius/chant/components/driver";
5
+ import { generateForgejoPipeline } from "./generate-pipeline";
6
+
7
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
8
+
9
+ test("a stale-notes job runs pr-post --stale on forgejo (#4628)", () => {
10
+ const doc = parseYAML(generateForgejoPipeline(ESTATE, { env: "prod", prLoop: { staleNotes: true } }).yaml) as { jobs: Record<string, { steps: Array<{ run?: string }> }> };
11
+ expect(doc.jobs["stale-notes"]!.steps.at(-1)!.run).toBe('chant components pr-post --stale --tip "$TIP_SHA" --env prod --forge forgejo');
12
+ });