@intentius/chant-lexicon-forgejo 0.124.54 → 0.124.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/components/ci-jobs.d.ts.map +1 -1
  2. package/dist/components/ci-jobs.js +1 -0
  3. package/dist/components/control-pipeline.d.ts +12 -0
  4. package/dist/components/control-pipeline.d.ts.map +1 -0
  5. package/dist/components/control-pipeline.js +20 -0
  6. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  7. package/dist/components/generate-op-pipeline.js +5 -1
  8. package/dist/components/generate-op-waves-pipeline.d.ts +6 -2
  9. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  10. package/dist/components/generate-op-waves-pipeline.js +7 -3
  11. package/dist/integrity.json +2 -2
  12. package/dist/manifest.json +1 -1
  13. package/package.json +3 -3
  14. package/src/components/__golden__/op-waves-jobs.yml +16 -0
  15. package/src/components/ci-jobs.ts +1 -0
  16. package/src/components/control-pipeline.ts +27 -0
  17. package/src/components/generate-op-pipeline.agent.test.ts +46 -0
  18. package/src/components/generate-op-pipeline.cloud.test.ts +25 -0
  19. package/src/components/generate-op-pipeline.ts +4 -1
  20. package/src/components/generate-op-waves-pipeline.approval.test.ts +26 -0
  21. package/src/components/generate-op-waves-pipeline.control.test.ts +38 -0
  22. package/src/components/generate-op-waves-pipeline.guard.test.ts +31 -0
  23. package/src/components/generate-op-waves-pipeline.test.ts +20 -2
  24. package/src/components/generate-op-waves-pipeline.ts +7 -3
  25. package/src/components/generate-pr-pipeline.comment-locks.test.ts +25 -0
  26. package/src/components/generate-pr-pipeline.comment-preview.test.ts +31 -0
  27. package/src/components/generate-pr-pipeline.comment-targets.test.ts +18 -0
  28. package/src/components/generate-pr-pipeline.control.test.ts +30 -0
  29. package/src/components/generate-pr-pipeline.runners.test.ts +24 -0
  30. package/src/components/generate-pr-pipeline.stale-notes.test.ts +12 -0
@@ -1 +1 @@
1
- {"version":3,"file":"ci-jobs.d.ts","sourceRoot":"","sources":["../../src/components/ci-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iEAAiE,CAAC;AAC3G,OAAO,EAA2B,KAAK,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAEjF,kEAAkE;AAClE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,qBAAqB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAElH;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ5F;AAED,0FAA0F;AAC1F,wBAAgB,wBAAwB,CAAC,eAAe,EAAE,MAAM,GAAG,MAAM,EAAE,CAQ1E;AAED,mHAAmH;AACnH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAOlF;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe;IAAK,iBAAiB;CAAqC,CAAC;AAExF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,EAAE,OAAO,EAAE,qBAAqB,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,mBAAmB,CAanI"}
1
+ {"version":3,"file":"ci-jobs.d.ts","sourceRoot":"","sources":["../../src/components/ci-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iEAAiE,CAAC;AAC3G,OAAO,EAA2B,KAAK,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAEjF,kEAAkE;AAClE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,qBAAqB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAElH;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ5F;AAED,0FAA0F;AAC1F,wBAAgB,wBAAwB,CAAC,eAAe,EAAE,MAAM,GAAG,MAAM,EAAE,CAQ1E;AAED,mHAAmH;AACnH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAOlF;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe;IAAK,iBAAiB;CAAqC,CAAC;AAExF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,EAAE,OAAO,EAAE,qBAAqB,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,mBAAmB,CAcnI"}
@@ -68,5 +68,6 @@ export function forgejoWorkflowDoc(doc, dialect, header) {
68
68
  jobsDoc: forgejoize(withoutEnvironment(doc.jobsDoc), dialect),
69
69
  ...(doc.gatedNoticeDoc ? { gatedNoticeDoc: forgejoize(doc.gatedNoticeDoc, dialect) } : {}),
70
70
  ...(doc.reviewNoteDoc ? { reviewNoteDoc: forgejoize(doc.reviewNoteDoc, dialect) } : {}),
71
+ ...(doc.agentDoc ? { agentDoc: forgejoize(doc.agentDoc, dialect) } : {}),
71
72
  };
72
73
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The control repo's own workflow (#4568) on Forgejo: github's document
3
+ * (`controlGithubDoc`) with the Forgejo dialect applied, the way
4
+ * ./generate-pipeline.ts reuses github's pipeline. Forgejo has no
5
+ * `repository_dispatch`, so a member's dispatch is a workflow dispatch of
6
+ * this file by name.
7
+ */
8
+ import { type ControlPipelineFile, type ControlPipelineOptions } from "@intentius/chant-lexicon-github/components/control-pipeline";
9
+ import type { ForgejoDialectOptions } from "../dialect.js";
10
+ /** The control repo's workflow on Forgejo: `.forgejo/workflows/<product>-dependents.yml`. */
11
+ export declare function generateControlPipeline(options?: ControlPipelineOptions, dialect?: ForgejoDialectOptions): ControlPipelineFile;
12
+ //# sourceMappingURL=control-pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/control-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAEL,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC5B,MAAM,6DAA6D,CAAC;AAErE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAGxD,6FAA6F;AAC7F,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,sBAA2B,EAAE,OAAO,GAAE,qBAA0B,GAAG,mBAAmB,CAQtI"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The control repo's own workflow (#4568) on Forgejo: github's document
3
+ * (`controlGithubDoc`) with the Forgejo dialect applied, the way
4
+ * ./generate-pipeline.ts reuses github's pipeline. Forgejo has no
5
+ * `repository_dispatch`, so a member's dispatch is a workflow dispatch of
6
+ * this file by name.
7
+ */
8
+ import { controlGithubDoc, } from "@intentius/chant-lexicon-github/components/control-pipeline";
9
+ import { emitWorkflowYAML } from "@intentius/chant-lexicon-github/components/ci-jobs";
10
+ import { forgejoize } from "./ci-jobs.js";
11
+ /** The control repo's workflow on Forgejo: `.forgejo/workflows/<product>-dependents.yml`. */
12
+ export function generateControlPipeline(options = {}, dialect = {}) {
13
+ const { doc, path, notes } = controlGithubDoc("forgejo", options);
14
+ const content = emitWorkflowYAML({
15
+ ...doc,
16
+ on: forgejoize(doc.on, dialect),
17
+ jobsDoc: forgejoize(doc.jobsDoc, dialect),
18
+ });
19
+ return { path, content, notes };
20
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;AAMH,OAAO,KAAK,EACV,wBAAwB,EACxB,gBAAgB,EAChB,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAGxD;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,wBAA6B,EACtC,cAAc,GAAE,qBAA0B,GACzC,gBAAgB,CAmBlB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;AAMH,OAAO,KAAK,EACV,wBAAwB,EACxB,gBAAgB,EAChB,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAGxD;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,wBAA6B,EACtC,cAAc,GAAE,qBAA0B,GACzC,gBAAgB,CAqBlB"}
@@ -86,6 +86,7 @@
86
86
  * sibling Op's job receives.
87
87
  */
88
88
  import { buildGithubOpPipelineDocs, emitOpPipelineYAML, } from "@intentius/chant-lexicon-github/components/generate-op-pipeline";
89
+ import { assertRunnerLabels } from "@intentius/chant/ci-jobs";
89
90
  import { droppedEnvironmentHeader, forgejoWorkflowDoc } from "./ci-jobs.js";
90
91
  /**
91
92
  * Synthesize one `.forgejo/workflows/*.yml` per scheduled Op. Reuses github's
@@ -100,7 +101,10 @@ export function generateForgejoOpPipeline(ops, options = {}, dialectOptions = {}
100
101
  // `findingMode: "comment"` used to be refused by name here (#2231), and
101
102
  // `findingMode: "issue"` from #2332 to #2351. Neither is now: both post
102
103
  // through the Forgejo client — see the module doc above.
103
- const { files, jobs } = buildGithubOpPipelineDocs(ops, options);
104
+ // #4616: a spec's runner is a label or a list of labels here.
105
+ for (const spec of ops)
106
+ assertRunnerLabels(`Scheduled Op "${spec.name}"`, "runner", spec.runner, "Forgejo");
107
+ const { files, jobs } = buildGithubOpPipelineDocs(ops, options, "forgejo");
104
108
  return {
105
109
  // One file per spec, in spec order, which is what lets the header below
106
110
  // read its Op's own `environment` off the input by position.
@@ -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": "0a6dc57e7d8145e558ff06623c2d0e382f169c0dd47004d1bec2807e7c424c08",
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": "3011e602acaca2080397cd441b1153f053cbc914e39da45949674d2055d02a37"
12
+ "composite": "7b0449ca0d3675a7e8c15a30fc85f3de807f6eb7eb538536867e77edd16ee133"
13
13
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgejo",
3
- "version": "0.124.54",
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.54",
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.54",
58
- "@intentius/chant-lexicon-github": "^0.124.54"
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
@@ -77,5 +77,6 @@ export function forgejoWorkflowDoc(doc: GithubOpPipelineDoc, dialect: ForgejoDia
77
77
  jobsDoc: forgejoize(withoutEnvironment(doc.jobsDoc), dialect),
78
78
  ...(doc.gatedNoticeDoc ? { gatedNoticeDoc: forgejoize(doc.gatedNoticeDoc, dialect) } : {}),
79
79
  ...(doc.reviewNoteDoc ? { reviewNoteDoc: forgejoize(doc.reviewNoteDoc, dialect) } : {}),
80
+ ...(doc.agentDoc ? { agentDoc: forgejoize(doc.agentDoc, dialect) } : {}),
80
81
  };
81
82
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The control repo's own workflow (#4568) on Forgejo: github's document
3
+ * (`controlGithubDoc`) with the Forgejo dialect applied, the way
4
+ * ./generate-pipeline.ts reuses github's pipeline. Forgejo has no
5
+ * `repository_dispatch`, so a member's dispatch is a workflow dispatch of
6
+ * this file by name.
7
+ */
8
+
9
+ import {
10
+ controlGithubDoc,
11
+ type ControlPipelineFile,
12
+ type ControlPipelineOptions,
13
+ } from "@intentius/chant-lexicon-github/components/control-pipeline";
14
+ import { emitWorkflowYAML } from "@intentius/chant-lexicon-github/components/ci-jobs";
15
+ import type { ForgejoDialectOptions } from "../dialect";
16
+ import { forgejoize } from "./ci-jobs";
17
+
18
+ /** The control repo's workflow on Forgejo: `.forgejo/workflows/<product>-dependents.yml`. */
19
+ export function generateControlPipeline(options: ControlPipelineOptions = {}, dialect: ForgejoDialectOptions = {}): ControlPipelineFile {
20
+ const { doc, path, notes } = controlGithubDoc("forgejo", options);
21
+ const content = emitWorkflowYAML({
22
+ ...doc,
23
+ on: forgejoize(doc.on, dialect),
24
+ jobsDoc: forgejoize(doc.jobsDoc, dialect),
25
+ });
26
+ return { path, content, notes };
27
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The agent jobs on Forgejo (#4616): github's jobs in the Forgejo dialect,
3
+ * with the commenter's access asked of the forge, and a runner a label.
4
+ */
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 { generateForgejoOpPipeline } from "./generate-op-pipeline";
10
+
11
+ const doc = (spec: ScheduledOpSpec) => parseYAML(generateForgejoOpPipeline([spec]).files[0]!.yaml) as unknown as { jobs: Record<string, { if?: string; "runs-on"?: unknown; steps: Array<{ run?: string; env?: Record<string, string> }> }> };
12
+
13
+ describe("the comment agent on Forgejo", () => {
14
+ const d = doc({ name: "chant-agent", runner: "agents", agent: { on: "comment", comment: "/chant agent" } });
15
+
16
+ test("answers a comment on a pull request with no author association to read", () => {
17
+ const agent = d.jobs["chant-agent"]!;
18
+ expect(agent.if).toContain("github.event.is_pull");
19
+ expect(agent.if).not.toContain("author_association");
20
+ expect(agent["runs-on"]).toBe("agents");
21
+ });
22
+
23
+ test("the push job hands the forge client its token", () => {
24
+ const land = d.jobs["chant-agent-push"]!.steps.at(-1)!;
25
+ expect(land.run).toContain("chant agent land --forge forgejo");
26
+ expect(land.env?.CHANT_FORGEJO_TOKEN).toBeDefined();
27
+ });
28
+
29
+ test("refuses a runner group, which only GitHub has", () => {
30
+ expect(() => doc({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, runner: { group: "infra" } })).toThrow(/runner group/);
31
+ });
32
+ });
33
+
34
+ // #4619: on Forgejo too, a member's drift Op hands its own output on, and the agent job takes it at the root.
35
+ describe("the drift agent of a workspace member on Forgejo", () => {
36
+ const out = generateForgejoOpPipeline([{ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, agent: { on: "drift" } }], { member: { name: "app", dir: "stacks/app" } });
37
+ const d = parseYAML(out.files[0]!.yaml) as unknown as { jobs: Record<string, { defaults?: { run?: { "working-directory"?: string } }; steps: Array<{ name?: string; run?: string }> }> };
38
+
39
+ test("the drift check runs in the member's directory, and the agent job reads what it handed on", () => {
40
+ expect(d.jobs["drift-app"]!.defaults?.run?.["working-directory"]).toBe("stacks/app");
41
+ const agent = d.jobs["drift-app-agent"]!;
42
+ expect(agent.defaults).toBeUndefined();
43
+ expect(agent.steps.find((s) => s.name === "Take the drift from the drift check")!.run).toContain(" .chant/agent <<");
44
+ expect(agent.steps.some((s) => s.run?.includes("chant agent prepare --forge forgejo --output .chant/agent"))).toBe(true);
45
+ });
46
+ });
@@ -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
+ });
@@ -95,6 +95,7 @@ import type {
95
95
  OpPipelineResult,
96
96
  ScheduledOpSpec,
97
97
  } from "@intentius/chant/lexicon";
98
+ import { assertRunnerLabels } from "@intentius/chant/ci-jobs";
98
99
  import type { ForgejoDialectOptions } from "../dialect";
99
100
  import { droppedEnvironmentHeader, forgejoWorkflowDoc } from "./ci-jobs";
100
101
 
@@ -116,7 +117,9 @@ export function generateForgejoOpPipeline(
116
117
  // `findingMode: "issue"` from #2332 to #2351. Neither is now: both post
117
118
  // through the Forgejo client — see the module doc above.
118
119
 
119
- const { files, jobs } = buildGithubOpPipelineDocs(ops, options);
120
+ // #4616: a spec's runner is a label or a list of labels here.
121
+ for (const spec of ops) assertRunnerLabels(`Scheduled Op "${spec.name}"`, "runner", spec.runner, "Forgejo");
122
+ const { files, jobs } = buildGithubOpPipelineDocs(ops, options, "forgejo");
120
123
 
121
124
  return {
122
125
  // One file per spec, in spec order, which is what lets the header below
@@ -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,38 @@
1
+ /**
2
+ * An Op waves spec's `control` on Forgejo (#4617): github's control job
3
+ * under the dialect, dispatching the control repo's dependents workflow by
4
+ * name with the outcome as an input. Written for the ci-products session;
5
+ * run at epic-ready.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
10
+ import { generateForgejoOpWavesPipeline } from "./generate-op-waves-pipeline";
11
+
12
+ const spec: OpWavesSpec = {
13
+ name: "stacks",
14
+ op: "pulumi-up",
15
+ plan: ["./bin/plan", "{target}", "{plan}"],
16
+ waves: [{ name: "all", runs: [{ target: "dev" }, { target: "prod" }] }],
17
+ control: { repo: "forgejo.example.com/acme/control", op: "deploy", member: "network", ref: "trunk" },
18
+ };
19
+
20
+ const options = { specFile: "waves.json", bin: "npx --no-install plywood", words: { product: "plywood" } };
21
+
22
+ describe("the control dispatch after the last wave on Forgejo (#4617)", () => {
23
+ test("a workflow dispatch of the control repo's dependents file, the outcome among its inputs", () => {
24
+ const yaml = generateForgejoOpWavesPipeline(spec, options).files[0]!.yaml;
25
+ expect(yaml).toMatch(/control-dispatch:[\s\S]*needs:\n\s+- wave-1-all\n\s+if: '!cancelled\(\)'/);
26
+ expect(yaml).toContain("https://forgejo.example.com/api/v1/repos/acme/control/actions/workflows/plywood-dependents.yml/dispatches");
27
+ expect(yaml).toContain(`{"ref":"trunk","inputs":{"member":"'"$member"'","op":"'"$op"'","sha":"'"$sha"'","outcome":"'"$outcome"'"}}`);
28
+ expect(yaml).toContain("Authorization: token %s");
29
+ expect(yaml).toContain("CHANT_CONTROL_TOKEN: '${{ secrets.CHANT_CONTROL_TOKEN }}'");
30
+ expect(yaml).toContain("npx --no-install plywood run wave --spec waves.json --waves-outcome");
31
+ });
32
+
33
+ test("the dialect drops permissions and rewrites the download action", () => {
34
+ const yaml = generateForgejoOpWavesPipeline(spec, options).files[0]!.yaml;
35
+ expect(yaml).not.toContain("permissions:");
36
+ expect(yaml).toContain("name: Download the waves' outcomes");
37
+ });
38
+ });
@@ -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,26 @@ 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
  });
123
+
124
+ test("words.bin is the command in every job line: the waves, resume and the plans job (#4607)", () => {
125
+ const bin = "npx --no-install plywood";
126
+ const withBin = { ...spec, resume: { schedule: "*/10 * * * *" }, waves: spec.waves.map((w, i) => (i === spec.waves.length - 1 ? { ...w, approval: "pr-review" as const } : w)) };
127
+ const { files, jobs } = generateForgejoOpWavesPipeline(withBin, { specFile: "waves.json", bin });
128
+ expect(jobs.every((j) => j.command.slice(0, 3).join(" ") === bin)).toBe(true);
129
+ const yaml = files.map((f) => f.yaml).join("\n");
130
+ expect(yaml).toContain("run: npx --no-install plywood run wave --spec waves.json --wave 1");
131
+ expect(yaml).toContain("npx --no-install plywood run resume --op migrations");
132
+ expect(yaml).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
133
+ expect(yaml).not.toMatch(/(^|\s)chant run (wave|resume)/m);
134
+ });
135
+
136
+ test("no bin keeps chant (#4607)", () => {
137
+ const { jobs } = generateForgejoOpWavesPipeline(spec, { specFile: "waves.json" });
138
+ expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
139
+ });
122
140
  });
@@ -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,25 @@
1
+ /** `/<product> lock` and `/<product> unlock` on Forgejo (#4588): the GitHub generator's comment jobs, reading Forgejo's `is_pull`, with no author check. */
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { parseYAML } from "@intentius/chant/yaml";
5
+ import type { DriverComponent } from "@intentius/chant/components/driver";
6
+ import { generateForgejoPipeline } from "./generate-pipeline";
7
+
8
+ const ESTATE: DriverComponent[] = [
9
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
10
+ ];
11
+
12
+ const WORDS = { product: "plywood", commands: { lock: "lock" as const, unlock: "unlock" as const } };
13
+
14
+ type Doc = { jobs: Record<string, { if?: string; steps: Array<{ run?: string }> }> };
15
+
16
+ describe("the Forgejo workflow's lock and unlock comments", () => {
17
+ const doc = parseYAML(generateForgejoPipeline(ESTATE, { env: "prod", prLoop: { locks: "plan", words: WORDS } }).yaml) as Doc;
18
+
19
+ test("each comment job reads is_pull and runs on forgejo", () => {
20
+ expect(doc.jobs["comment-lock"]!.if).toContain("github.event.is_pull");
21
+ expect(doc.jobs["comment-lock"]!.if).not.toContain("author_association");
22
+ expect(doc.jobs["comment-lock"]!.steps.some((s) => s.run === 'chant lock --keep --pr "$PR_NUMBER" --forge forgejo')).toBe(true);
23
+ expect(doc.jobs["comment-unlock"]!.steps.some((s) => s.run === 'chant unlock --pr "$PR_NUMBER"')).toBe(true);
24
+ });
25
+ });
@@ -0,0 +1,31 @@
1
+ /** The preview comment on Forgejo (#4620): the GitHub generator's jobs, reading Forgejo's `is_pull`, with no author check. */
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { parseYAML } from "@intentius/chant/yaml";
5
+ import type { DriverComponent } from "@intentius/chant/components/driver";
6
+ import { generateForgejoPipeline } from "./generate-pipeline";
7
+
8
+ const ESTATE: DriverComponent[] = [
9
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
10
+ ];
11
+
12
+ const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const } };
13
+
14
+ type Doc = { jobs: Record<string, { if?: string; permissions?: Record<string, string>; steps: Array<{ run?: string }> }> };
15
+
16
+ describe("the Forgejo workflow's preview comment", () => {
17
+ const doc = parseYAML(generateForgejoPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as Doc;
18
+
19
+ test("the preview job reads is_pull, drops the tokens before the change's code runs, and the note job posts on forgejo", () => {
20
+ const preview = doc.jobs["comment-preview"]!;
21
+ expect(preview.if).toContain("github.event.is_pull");
22
+ expect(preview.if).not.toContain("author_association");
23
+ // The Forgejo runner ignores `permissions`, so the dialect drops it (bar id-token), as for every job.
24
+ expect(preview.permissions).toBeUndefined();
25
+ const drop = preview.steps.findIndex((s) => s.run?.includes("-u CHANT_FORGE_TOKEN") ?? false);
26
+ const plan = preview.steps.findIndex((s) => s.run?.includes("pr-plan") ?? false);
27
+ expect(drop).toBeGreaterThan(0);
28
+ expect(plan).toBeGreaterThan(drop);
29
+ expect(doc.jobs["comment-preview-note"]!.steps.at(-1)!.run).toBe('chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge forgejo --result "$PLAN_RESULT"');
30
+ });
31
+ });
@@ -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,30 @@
1
+ /**
2
+ * #4619: with a control repo, the Forgejo pull-request workflow's plan-note
3
+ * job fetches the control repo's chant/lifecycle, read-only, and sets
4
+ * CHANT_CONTROL_DIR before it posts, through the Forgejo dialect. Generated
5
+ * YAML only.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { DriverComponent } from "@intentius/chant/components/driver";
11
+ import { resolveControlTarget } from "@intentius/chant/control/dispatch";
12
+ import { generateForgejoPipeline } from "./generate-pipeline";
13
+
14
+ const root = (name: string, dependsOn: string[] = []): DriverComponent => ({ name, dependsOn, deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }] });
15
+ const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
16
+ const control = { target: resolveControlTarget({ repo: "forgejo.example.com/acme/control", op: "deploy", readTokenSecret: "PLYWOOD_CONTROL_READ" }, "network") };
17
+
18
+ describe("the Forgejo plan-note job's read of the control repo (#4619)", () => {
19
+ test("fetches the control repo's chant/lifecycle with the read secret before it posts", () => {
20
+ const { yaml } = generateForgejoPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control, words: { product: "plywood" } } });
21
+ const doc = parseYAML(yaml) as { jobs: Record<string, { steps: Array<{ run?: string; env?: Record<string, string> }> }> };
22
+ const steps = doc.jobs["plan-note"]!.steps;
23
+ const read = steps.findIndex((s) => s.run?.includes("refs/heads/chant/lifecycle"));
24
+ expect(read).toBeGreaterThan(-1);
25
+ expect(steps[read + 1]!.run).toContain("components pr-post");
26
+ expect(steps[read]!.env?.CHANT_CONTROL_READ_TOKEN).toBe("${{ secrets.PLYWOOD_CONTROL_READ || github.token }}");
27
+ expect(steps[read]!.run).toContain("'https://forgejo.example.com/acme/control.git'");
28
+ expect(steps[read]!.run).toContain(">>\"$GITHUB_ENV\"");
29
+ });
30
+ });
@@ -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
+ });