@intentius/chant-lexicon-forgejo 0.124.55 → 0.124.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ci-jobs.js +1 -1
- package/dist/components/generate-op-waves-pipeline.d.ts +14 -2
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +15 -3
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +3 -3
- package/src/components/__golden__/op-waves-jobs.yml +27 -4
- package/src/components/ci-jobs.ts +1 -1
- package/src/components/generate-op-pipeline.cloud.test.ts +25 -0
- package/src/components/generate-op-waves-pipeline.approval.test.ts +26 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +31 -0
- package/src/components/generate-op-waves-pipeline.test.ts +14 -4
- package/src/components/generate-op-waves-pipeline.ts +15 -3
- package/src/components/generate-pr-pipeline.comment-targets.test.ts +18 -0
- package/src/components/generate-pr-pipeline.locks.test.ts +7 -0
- package/src/components/generate-pr-pipeline.runners.test.ts +24 -0
- package/src/components/generate-pr-pipeline.stale-notes.test.ts +12 -0
|
@@ -63,7 +63,7 @@ export function forgejoWorkflowDoc(doc, dialect, header) {
|
|
|
63
63
|
...(doc.name ? { name: doc.name } : {}),
|
|
64
64
|
on: forgejoize(doc.on, dialect),
|
|
65
65
|
...(doc.env ? { env: forgejoize(doc.env, dialect) } : {}),
|
|
66
|
-
concurrency: forgejoize(doc.concurrency, dialect),
|
|
66
|
+
...(doc.concurrency ? { concurrency: forgejoize(doc.concurrency, dialect) } : {}),
|
|
67
67
|
permissions: {},
|
|
68
68
|
jobsDoc: forgejoize(withoutEnvironment(doc.jobsDoc), dialect),
|
|
69
69
|
...(doc.gatedNoticeDoc ? { gatedNoticeDoc: forgejoize(doc.gatedNoticeDoc, dialect) } : {}),
|
|
@@ -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)
|
|
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,18 @@
|
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* Like GitHub's, the workflow carries no concurrency group (ruling for
|
|
30
|
+
* ci-products 2026-10-28, "Ledger lock"; Forgejo 16.0.5 has no job-level
|
|
31
|
+
* concurrency either). Two pushes run their waves side by side: a stock
|
|
32
|
+
* Pulumi stack or a Terraform root waits for its row on chant's ledger,
|
|
33
|
+
* oldest commit first, up to the job's `timeout-minutes`, and a plunge stack
|
|
34
|
+
* on the resource grain leases its resource rows and never waits on another
|
|
35
|
+
* stack.
|
|
24
36
|
*/
|
|
25
37
|
import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
26
38
|
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
|
|
1
|
+
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;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)
|
|
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,18 @@
|
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* Like GitHub's, the workflow carries no concurrency group (ruling for
|
|
30
|
+
* ci-products 2026-10-28, "Ledger lock"; Forgejo 16.0.5 has no job-level
|
|
31
|
+
* concurrency either). Two pushes run their waves side by side: a stock
|
|
32
|
+
* Pulumi stack or a Terraform root waits for its row on chant's ledger,
|
|
33
|
+
* oldest commit first, up to the job's `timeout-minutes`, and a plunge stack
|
|
34
|
+
* on the resource grain leases its resource rows and never waits on another
|
|
35
|
+
* stack.
|
|
24
36
|
*/
|
|
25
37
|
import { buildGithubOpWavesDoc } from "@intentius/chant-lexicon-github/components/generate-op-waves-pipeline";
|
|
26
38
|
import { emitOpPipelineYAML } from "@intentius/chant-lexicon-github/components/generate-op-pipeline";
|
|
@@ -29,7 +41,7 @@ import { droppedWaveEnvironmentsHeader, FORGE_TOKEN_ENV, forgejoWorkflowDoc } fr
|
|
|
29
41
|
/** Render an Op waves spec as one Forgejo Actions workflow, `<name>.yml`. */
|
|
30
42
|
export function generateForgejoOpWavesPipeline(spec, options, dialectOptions = {}) {
|
|
31
43
|
assertForgejoWaves(spec);
|
|
32
|
-
const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options);
|
|
44
|
+
const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options, { forge: "forgejo" });
|
|
33
45
|
const dropped = spec.waves.filter((w) => w.environment).map((w) => `${w.name} -> ${w.environment.name}`);
|
|
34
46
|
const header = dropped.length > 0 ? droppedWaveEnvironmentsHeader(dropped) : undefined;
|
|
35
47
|
const files = [{ name: `${spec.name}.yml`, yaml: emitOpPipelineYAML(forgejoWorkflowDoc(doc, dialectOptions, header)) }];
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "4c4c5e8764f6844c5a23d70e6afa86d905c6627e01d04a2463e9a6f40d857e2c",
|
|
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": "
|
|
12
|
+
"composite": "4226c270f3ec6aed2feaee4293a384febb3a87163e9c1256fd58813717a46592"
|
|
13
13
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-forgejo",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.57",
|
|
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.
|
|
58
|
-
"@intentius/chant-lexicon-github": "^0.124.
|
|
57
|
+
"@intentius/chant": "^0.124.57",
|
|
58
|
+
"@intentius/chant-lexicon-github": "^0.124.57"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"generate": "tsx src/codegen/generate-cli.ts",
|
|
@@ -6,15 +6,18 @@ on:
|
|
|
6
6
|
- main
|
|
7
7
|
workflow_dispatch: {}
|
|
8
8
|
|
|
9
|
-
concurrency:
|
|
10
|
-
group: migrations
|
|
11
|
-
cancel-in-progress: false
|
|
12
|
-
|
|
13
9
|
jobs:
|
|
14
10
|
wave-1-dev:
|
|
15
11
|
runs-on: plan
|
|
16
12
|
container: node:22
|
|
13
|
+
timeout-minutes: 180
|
|
14
|
+
env:
|
|
15
|
+
CHANT_LOCK_WAIT: '10500'
|
|
17
16
|
steps:
|
|
17
|
+
- name: Check the job runs where the spec set it to run
|
|
18
|
+
env:
|
|
19
|
+
CHANT_JOB_GUARD: '{"forge":"forgejo","job":"wave 1 (dev) of migrations","branches":["main"]}'
|
|
20
|
+
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
21
|
- uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
19
22
|
with:
|
|
20
23
|
fetch-depth: 0
|
|
@@ -33,7 +36,14 @@ jobs:
|
|
|
33
36
|
- wave-1-dev
|
|
34
37
|
if: '!cancelled()'
|
|
35
38
|
container: node:22
|
|
39
|
+
timeout-minutes: 180
|
|
40
|
+
env:
|
|
41
|
+
CHANT_LOCK_WAIT: '10500'
|
|
36
42
|
steps:
|
|
43
|
+
- name: Check the job runs where the spec set it to run
|
|
44
|
+
env:
|
|
45
|
+
CHANT_JOB_GUARD: '{"forge":"forgejo","job":"the deciding job of wave 2 (prod) of migrations","branches":["main"]}'
|
|
46
|
+
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
47
|
- uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
38
48
|
with:
|
|
39
49
|
fetch-depth: 0
|
|
@@ -66,10 +76,16 @@ jobs:
|
|
|
66
76
|
container: node:22
|
|
67
77
|
permissions:
|
|
68
78
|
id-token: write
|
|
79
|
+
timeout-minutes: 180
|
|
69
80
|
env:
|
|
81
|
+
CHANT_LOCK_WAIT: '10500'
|
|
70
82
|
REGION: us
|
|
71
83
|
CLOUD_TOKEN_AUDIENCE: us.example
|
|
72
84
|
steps:
|
|
85
|
+
- name: Check the job runs where the spec set it to run
|
|
86
|
+
env:
|
|
87
|
+
CHANT_JOB_GUARD: '{"forge":"forgejo","job":"job group us of wave 2 (prod) of migrations","branches":["main"],"selfHosted":true}'
|
|
88
|
+
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
89
|
- uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
74
90
|
with:
|
|
75
91
|
fetch-depth: 0
|
|
@@ -96,7 +112,14 @@ jobs:
|
|
|
96
112
|
needs:
|
|
97
113
|
- wave-2-prod-decide
|
|
98
114
|
container: node:22
|
|
115
|
+
timeout-minutes: 180
|
|
116
|
+
env:
|
|
117
|
+
CHANT_LOCK_WAIT: '10500'
|
|
99
118
|
steps:
|
|
119
|
+
- name: Check the job runs where the spec set it to run
|
|
120
|
+
env:
|
|
121
|
+
CHANT_JOB_GUARD: '{"forge":"forgejo","job":"job group eu of wave 2 (prod) of migrations","branches":["main"]}'
|
|
122
|
+
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
123
|
- uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
101
124
|
with:
|
|
102
125
|
fetch-depth: 0
|
|
@@ -72,7 +72,7 @@ export function forgejoWorkflowDoc(doc: GithubOpPipelineDoc, dialect: ForgejoDia
|
|
|
72
72
|
...(doc.name ? { name: doc.name } : {}),
|
|
73
73
|
on: forgejoize(doc.on, dialect),
|
|
74
74
|
...(doc.env ? { env: forgejoize(doc.env, dialect) } : {}),
|
|
75
|
-
concurrency: forgejoize(doc.concurrency, dialect),
|
|
75
|
+
...(doc.concurrency ? { concurrency: forgejoize(doc.concurrency, dialect) } : {}),
|
|
76
76
|
permissions: {},
|
|
77
77
|
jobsDoc: forgejoize(withoutEnvironment(doc.jobsDoc), dialect),
|
|
78
78
|
...(doc.gatedNoticeDoc ? { gatedNoticeDoc: forgejoize(doc.gatedNoticeDoc, dialect) } : {}),
|
|
@@ -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
|
+
});
|
|
@@ -90,8 +90,8 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
|
|
|
90
90
|
expect(jobs.map((j) => j.jobName)).toEqual(["wave-1-dev", "wave-2-prod-decide", "wave-2-prod-us", "wave-2-prod-eu"]);
|
|
91
91
|
const yaml = files[0]!.yaml;
|
|
92
92
|
expect(yaml).toMatch(/wave-1-dev:\n\s+runs-on: plan\n/);
|
|
93
|
-
expect(yaml).toMatch(/wave-2-prod-us:\n\s+runs-on:\n\s+- self-hosted\n\s+- us\n[\s\S]*?permissions:\n\s+id-token: write\n\s+env:\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/);
|
|
94
|
-
expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+steps:/);
|
|
93
|
+
expect(yaml).toMatch(/wave-2-prod-us:\n\s+runs-on:\n\s+- self-hosted\n\s+- us\n[\s\S]*?permissions:\n\s+id-token: write\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/);
|
|
94
|
+
expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+timeout-minutes: 180\n\s+env:\n\s+CHANT_LOCK_WAIT: '10500'\n\s+steps:/);
|
|
95
95
|
expect(yaml).not.toContain("contents: write");
|
|
96
96
|
});
|
|
97
97
|
|
|
@@ -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
|
-
|
|
119
|
-
expect(job.steps[1]!.
|
|
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
|
|
|
@@ -137,3 +138,12 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
|
|
|
137
138
|
expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
|
|
138
139
|
});
|
|
139
140
|
});
|
|
141
|
+
|
|
142
|
+
describe("the ledger lock (ruling for ci-products 2026-10-28)", () => {
|
|
143
|
+
test("the waves workflow carries no concurrency group, workflow or job; each wave job carries its timeout and the lock wait", () => {
|
|
144
|
+
const yaml = generateForgejoOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
|
|
145
|
+
expect(yaml).not.toContain("concurrency:");
|
|
146
|
+
expect(yaml.match(/timeout-minutes: 180\n/g)).toHaveLength(3);
|
|
147
|
+
expect(yaml.match(/CHANT_LOCK_WAIT: '10500'\n/g)).toHaveLength(3);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -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)
|
|
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,18 @@
|
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* Like GitHub's, the workflow carries no concurrency group (ruling for
|
|
30
|
+
* ci-products 2026-10-28, "Ledger lock"; Forgejo 16.0.5 has no job-level
|
|
31
|
+
* concurrency either). Two pushes run their waves side by side: a stock
|
|
32
|
+
* Pulumi stack or a Terraform root waits for its row on chant's ledger,
|
|
33
|
+
* oldest commit first, up to the job's `timeout-minutes`, and a plunge stack
|
|
34
|
+
* on the resource grain leases its resource rows and never waits on another
|
|
35
|
+
* stack.
|
|
24
36
|
*/
|
|
25
37
|
|
|
26
38
|
import { buildGithubOpWavesDoc } from "@intentius/chant-lexicon-github/components/generate-op-waves-pipeline";
|
|
@@ -38,7 +50,7 @@ export function generateForgejoOpWavesPipeline(
|
|
|
38
50
|
dialectOptions: ForgejoDialectOptions = {},
|
|
39
51
|
): OpWavesPipelineResult {
|
|
40
52
|
assertForgejoWaves(spec);
|
|
41
|
-
const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options);
|
|
53
|
+
const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options, { forge: "forgejo" });
|
|
42
54
|
const dropped = spec.waves.filter((w) => w.environment).map((w) => `${w.name} -> ${w.environment!.name}`);
|
|
43
55
|
const header = dropped.length > 0 ? droppedWaveEnvironmentsHeader(dropped) : undefined;
|
|
44
56
|
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
|
+
});
|
|
@@ -22,6 +22,13 @@ describe("the Forgejo workflow with ci.locks: plan", () => {
|
|
|
22
22
|
expect(doc.jobs.unlock!.steps.some((s) => s.run === 'chant unlock --pr "$PR_NUMBER"')).toBe(true);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
test("with plywood's words the lines run through its bin (plywood#22)", () => {
|
|
26
|
+
const words = { product: "plywood", bin: "npx --no-install plywood" };
|
|
27
|
+
const own = parseYAML(generateForgejoPipeline(ESTATE, { env: "prod", prLoop: { locks: "plan", words } }).yaml) as Doc;
|
|
28
|
+
expect(own.jobs.lock!.steps.some((s) => s.run?.startsWith("npx --no-install plywood lock --from-report ") && s.run.endsWith("--forge forgejo"))).toBe(true);
|
|
29
|
+
expect(own.jobs.unlock!.steps.some((s) => s.run === 'npx --no-install plywood unlock --pr "$PR_NUMBER"')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
25
32
|
test("without ci.locks neither job is written", () => {
|
|
26
33
|
const plain = parseYAML(generateForgejoPipeline(ESTATE, { env: "prod", prLoop: {} }).yaml) as Doc;
|
|
27
34
|
expect(Object.keys(plain.jobs)).not.toContain("lock");
|
|
@@ -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
|
+
});
|