@intentius/chant-lexicon-forgejo 0.106.1 → 0.106.3

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/README.md CHANGED
@@ -33,9 +33,11 @@ On build, the Forgejo dialect:
33
33
 
34
34
  - **Drops keys Forgejo ignores** — `continue-on-error` is silently ignored by
35
35
  the Forgejo runner, so it is removed from the output and reported as a build
36
- warning (emitting it is misleading). `permissions` is kept, so a job can
37
- declare `id-token: write` to request an OIDC token; a Forgejo version that
38
- does not honour it ignores the key.
36
+ warning (emitting it is misleading). `permissions` is dropped too, because
37
+ Forgejo reads none of its scopes and lists the key in the run page's
38
+ "Workflow warnings" box. The one exception is `id-token`: a job that assumes
39
+ a cloud role over OIDC keeps `id-token: write`, and nothing else from its
40
+ `permissions` block.
39
41
  - **Maps runner labels** — GitHub-hosted labels like `ubuntu-latest` have no
40
42
  fixed meaning on Forgejo. They are mapped to a default Forgejo label
41
43
  (`docker`), overridable per project. Unmapped labels pass through with a
@@ -77,7 +79,7 @@ fate across the edge:
77
79
  |---|---|
78
80
  | `translated` | carried across as-is |
79
81
  | `approximated` | carried with a close equivalent |
80
- | `needs-review` | confirm/adjust on Forgejo (`permissions`, unresolved `uses:`, unmapped runner label) |
82
+ | `needs-review` | confirm/adjust on Forgejo (`permissions`, which the runner does not enforce, unresolved `uses:`, unmapped runner label) |
81
83
  | `lost` | the Forgejo runner ignores it (`continue-on-error`) |
82
84
 
83
85
  The same view is available to agents as the `forgejo:compare` MCP tool, which
@@ -10,8 +10,8 @@
10
10
  * before emitting.
11
11
  *
12
12
  * One difference from the component generator: Forgejo Actions ignores
13
- * `permissions:` entirely (the dialect drops it wherever it appears, see
14
- * ../dialect.ts's `DROPPED_KEYS`), so this omits the section outright rather
13
+ * the workflow-level `permissions:` section (the dialect drops every scope
14
+ * but `id-token`, see ../dialect.ts), so this omits the section outright rather
15
15
  * than emitting a control the runner can't act on. The token a finding-mode
16
16
  * needs still rides the trigger step's `env:` (`GH_TOKEN`/`GITHUB_TOKEN`,
17
17
  * built by github's generator); actual write access on Forgejo is a property
@@ -16,9 +16,8 @@
16
16
  * (default `docker`, the label a fresh `act_runner` exposes);
17
17
  * - `uses:` refs (`actions/checkout@v4`, `actions/upload-artifact@v4`, …)
18
18
  * rewritten to a Forgejo-resolvable form;
19
- * - keys the Forgejo runner ignores (`permissions`, `continue-on-error`)
20
- * dropped — the github generator emits none today, but the transform keeps
21
- * the two dialects in lock-step if that changes.
19
+ * - keys the Forgejo runner ignores (`continue-on-error`, and `permissions` bar `id-token`)
20
+ * dropped (`permissions` keeps only `id-token`, which OIDC needs).
22
21
  *
23
22
  * The output path convention is `.forgejo/workflows/` (vs github's
24
23
  * `.github/workflows/`), set by the caller's `-o` flag — this module emits the
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAC1E,OAAO,KAAK,EACV,wBAAwB,EACxB,uBAAuB,EACxB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAA2B,KAAK,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAOjF;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,wBAA6B,EACtC,cAAc,GAAE,qBAA0B,GACzC,uBAAuB,CAsBzB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAC1E,OAAO,KAAK,EACV,wBAAwB,EACxB,uBAAuB,EACxB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAA2B,KAAK,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAOjF;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,wBAA6B,EACtC,cAAc,GAAE,qBAA0B,GACzC,uBAAuB,CAsBzB"}
package/dist/dialect.d.ts CHANGED
@@ -7,10 +7,13 @@
7
7
  * a pre-pass over the resolved entity graph before the github serializer runs:
8
8
  *
9
9
  * 1. `continue-on-error` is silently ignored by the Forgejo runner — we drop
10
- * it from the output and warn per occurrence. `permissions` is kept: a
11
- * Forgejo version that honours it (15 and later may) needs `id-token: write`
12
- * on a job to request an OIDC token, and an older runner ignores the key.
13
- * There is no version setting in the dialect, so the key always passes.
10
+ * it from the output and warn per occurrence. `permissions` is reduced to
11
+ * the one scope a Forgejo job can still use: `id-token`, which a job that
12
+ * assumes a cloud role over OIDC needs (#3500). Every other scope
13
+ * (`contents`, `pull-requests`, ...) is dropped without a warning, since
14
+ * Forgejo reads none of them and flags the key in the run page's
15
+ * "Workflow warnings" box. A `permissions` block with no `id-token` goes
16
+ * away whole; one with it keeps only that line.
14
17
  * 2. GitHub-hosted runner labels (`ubuntu-latest`, …) have no fixed meaning on
15
18
  * Forgejo — we map them to a default Forgejo label, overridable per project.
16
19
  * 3. Anything we can't place (an unmapped runner label) passes through with a
@@ -1 +1 @@
1
- {"version":3,"file":"dialect.d.ts","sourceRoot":"","sources":["../src/dialect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAMH,OAAO,EAAsC,KAAK,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAgBlG;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAKxD,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,mFAAmF;IACnF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,oBAAoB;IACnC,sEAAsE;IACtE,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAClC,iEAAiE;IACjE,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAmFD,MAAM,WAAW,qBAAqB;IACpC,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAC;IACf,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,OAAO,EACZ,OAAO,GAAE,qBAA0B,GAClC,qBAAqB,CAKvB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,EACjC,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAWtB"}
1
+ {"version":3,"file":"dialect.d.ts","sourceRoot":"","sources":["../src/dialect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,EAAsC,KAAK,UAAU,EAAE,MAAM,6BAA6B,CAAC;AA4ClG;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAKxD,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,mFAAmF;IACnF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,oBAAoB;IACnC,sEAAsE;IACtE,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAClC,iEAAiE;IACjE,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAwFD,MAAM,WAAW,qBAAqB;IACpC,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAC;IACf,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,OAAO,EACZ,OAAO,GAAE,qBAA0B,GAClC,qBAAqB,CAKvB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,EACjC,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAWtB"}
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "95e25f88b60f33d218f764470da8c67db7be1d3ea2b8281d886b2b292e9f643f",
4
+ "manifest.json": "b763478472c9951b4a9071719b721c2b70cde45b08d1d3402198dc3559462a93",
5
5
  "meta.json": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
6
6
  "types/index.d.ts": "66204549b2a864ab5489e3f02c1878e73ab4b3c9d938cfb480b66f71543a43de",
7
7
  "rules/delegate-to-github.ts": "1060cda40f4b73d6cca3ba3fa13b80b4886147d4034c88584c2d80c5510dbe5e",
8
8
  "rules/wfj010.ts": "f32834f8b8d0a8dd2a8e19f456df7e44aa63084cce3022ccb13f58f4a9f4f75c",
9
9
  "rules/wfj011.ts": "fcb8bf6685d744af20ad804ce929488257995c74b9a3353b2eacae3768d1c83e",
10
- "skills/chant-forgejo.md": "a1a560429db736c187e0b34cf8dd9efc6ad771afefcbf6f802d160d3d5274257"
10
+ "skills/chant-forgejo.md": "f28d7fb69fb1cfd992496ef41fda0ea5ccd54a29b84ea1c501a44f6a3992c4d5"
11
11
  },
12
- "composite": "2730d8d463e46849a79a58bc89e6be14149429b95c72faf413139f6ef9b751a4"
12
+ "composite": "17bd17ce87e6eb4c34d0b217d0f93a455a39442a89c38166e6042afbe02c5f96"
13
13
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgejo",
3
- "version": "0.106.1",
3
+ "version": "0.106.3",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Forgejo",
6
6
  "intrinsics": [],
@@ -4,8 +4,8 @@
4
4
  * github → forgejo YAML is near-identical, so the migration itself is thin. The
5
5
  * differentiated value is the **compare**: classifying what survives the move
6
6
  * and what Forgejo silently drops. Most properties translate verbatim; the
7
- * useful findings are the keys the Forgejo runner ignores (`permissions`,
8
- * `continue-on-error` → `lost`) plus refs/labels that need attention
7
+ * useful findings are the keys the Forgejo runner ignores (`permissions` →
8
+ * `needs-review`, `continue-on-error` → `lost`) plus refs/labels that need attention
9
9
  * (unresolved `uses:`, unmapped runner labels → `needs-review`).
10
10
  *
11
11
  * Mirrors the gitlab lexicon's fate model (translated / approximated /
@@ -38,8 +38,9 @@ chant build src -o .forgejo/workflows/ci.yml
38
38
 
39
39
  ## What the dialect does on build
40
40
 
41
- - **Drops keys the Forgejo runner ignores** — `permissions` and
42
- `continue-on-error` are removed (each emits a build warning).
41
+ - **Drops keys the Forgejo runner ignores** — `continue-on-error` is removed
42
+ with a build warning. `permissions` is removed silently, except `id-token`,
43
+ which stays for OIDC.
43
44
  - **Maps runner labels** — `ubuntu-latest` → `docker` by default; override via
44
45
  `forgejo.runnerLabels` in `chant.config.ts`. Unmapped labels warn.
45
46
  - **Resolves `uses:` refs** — common `actions/*` rewrite under
@@ -53,7 +54,7 @@ chant migrate .github/workflows/ci.yml --to forgejo -o .forgejo/workflows/ci.yml
53
54
  ```
54
55
 
55
56
  `--validate` prints a **Security posture** report: what survives the move and
56
- what Forgejo silently drops (`permissions`/`continue-on-error` → lost,
57
+ what Forgejo silently drops (`continue-on-error` → lost, `permissions` → needs-review,
57
58
  unresolved `uses:` / unmapped runner labels → needs-review). The same view is
58
59
  the `forgejo:compare` MCP tool.
59
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-forgejo",
3
- "version": "0.106.1",
3
+ "version": "0.106.3",
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.106.1",
58
- "@intentius/chant-lexicon-github": "^0.106.1"
57
+ "@intentius/chant": "^0.106.3",
58
+ "@intentius/chant-lexicon-github": "^0.106.3"
59
59
  },
60
60
  "scripts": {
61
61
  "generate": "tsx src/codegen/generate-cli.ts",
@@ -16,10 +16,6 @@ jobs:
16
16
  if: github.event_name == 'pull_request'
17
17
  runs-on: docker
18
18
  container: node:22
19
- permissions:
20
- contents: read
21
- pull-requests: write
22
- statuses: write
23
19
  steps:
24
20
  - uses: https://code.forgejo.org/actions/checkout@v7
25
21
  with:
@@ -46,10 +42,6 @@ jobs:
46
42
  concurrency:
47
43
  group: chant-apply-prod
48
44
  cancel-in-progress: false
49
- permissions:
50
- contents: write
51
- pull-requests: write
52
- statuses: write
53
45
  steps:
54
46
  - uses: https://code.forgejo.org/actions/checkout@v7
55
47
  with:
@@ -10,8 +10,8 @@
10
10
  * before emitting.
11
11
  *
12
12
  * One difference from the component generator: Forgejo Actions ignores
13
- * `permissions:` entirely (the dialect drops it wherever it appears, see
14
- * ../dialect.ts's `DROPPED_KEYS`), so this omits the section outright rather
13
+ * the workflow-level `permissions:` section (the dialect drops every scope
14
+ * but `id-token`, see ../dialect.ts), so this omits the section outright rather
15
15
  * than emitting a control the runner can't act on. The token a finding-mode
16
16
  * needs still rides the trigger step's `env:` (`GH_TOKEN`/`GITHUB_TOKEN`,
17
17
  * built by github's generator); actual write access on Forgejo is a property
@@ -15,7 +15,7 @@ describe("generateForgejoPipeline for a workspace member", () => {
15
15
  test("working directory and name survive the dialect, and the triggers stay the plain pipeline's", () => {
16
16
  const member = { name: "api", dir: "services/api", file: ".forgejo/workflows/chant-api-staging.yml" };
17
17
  const doc = parseYAML(generateForgejoPipeline(components, { env: "staging", member }).yaml) as Record<string, unknown>;
18
- expect(doc.name).toBe("chant-components-api-staging");
18
+ expect(doc.name).toBe("chant-components.api.staging");
19
19
  expect(Object.keys(doc.on as Record<string, unknown>)).toEqual(["workflow_dispatch"]);
20
20
  expect(doc.defaults).toEqual({ run: { "working-directory": "services/api" } });
21
21
  });
@@ -16,9 +16,8 @@
16
16
  * (default `docker`, the label a fresh `act_runner` exposes);
17
17
  * - `uses:` refs (`actions/checkout@v4`, `actions/upload-artifact@v4`, …)
18
18
  * rewritten to a Forgejo-resolvable form;
19
- * - keys the Forgejo runner ignores (`permissions`, `continue-on-error`)
20
- * dropped — the github generator emits none today, but the transform keeps
21
- * the two dialects in lock-step if that changes.
19
+ * - keys the Forgejo runner ignores (`continue-on-error`, and `permissions` bar `id-token`)
20
+ * dropped (`permissions` keeps only `id-token`, which OIDC needs).
22
21
  *
23
22
  * The output path convention is `.forgejo/workflows/` (vs github's
24
23
  * `.github/workflows/`), set by the caller's `-o` flag — this module emits the
@@ -38,11 +38,11 @@ describe("the Forgejo pull-request workflow", () => {
38
38
  expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
39
39
  });
40
40
 
41
- test("its commands talk to Forgejo, on Forgejo's runner, with the job permissions kept", () => {
41
+ test("its commands talk to Forgejo, on Forgejo's runner, with the job permissions dropped", () => {
42
42
  expect(result.yaml).toContain("--forge forgejo");
43
43
  expect(result.yaml).not.toContain("--forge github");
44
44
  expect(result.yaml).toContain("runs-on: docker");
45
- expect(result.yaml).toMatch(/^\s+permissions:/m);
45
+ expect(result.yaml).not.toMatch(/^\s*permissions:/m);
46
46
  });
47
47
 
48
48
  test("the apply keeps its resume record in the runner's cache, through the Forgejo mirror of actions/cache (#3543)", () => {
@@ -31,16 +31,16 @@ function entityMap(...pairs: Array<[string, Declarable]>): Map<string, Declarabl
31
31
  }
32
32
 
33
33
  describe("applyForgejoDialect — dropped keys", () => {
34
- test("keeps workflow-level permissions without warning", () => {
34
+ test("drops workflow-level permissions without warning", () => {
35
35
  const wf = new MockWorkflow({ name: "CI", permissions: { contents: "read" } });
36
36
  const { entities, warnings } = applyForgejoDialect(entityMap(["workflow", wf]));
37
37
  const props = (entities.get("workflow") as MockWorkflow).props;
38
- expect(props.permissions).toEqual({ contents: "read" });
38
+ expect("permissions" in props).toBe(false);
39
39
  expect(props.name).toBe("CI");
40
40
  expect(warnings.filter((w) => w.includes("permissions"))).toHaveLength(0);
41
41
  });
42
42
 
43
- test("keeps job permissions, so a job can request an OIDC token", () => {
43
+ test("keeps only id-token from job permissions, so a job can request an OIDC token", () => {
44
44
  const job = new MockJob({
45
45
  "runs-on": "ubuntu-latest",
46
46
  permissions: { "id-token": "write", contents: "read" },
@@ -48,11 +48,34 @@ describe("applyForgejoDialect — dropped keys", () => {
48
48
  });
49
49
  const { entities, warnings } = applyForgejoDialect(entityMap(["job", job]));
50
50
  const props = (entities.get("job") as MockJob).props;
51
- expect(props.permissions).toEqual({ "id-token": "write", contents: "read" });
51
+ expect(props.permissions).toEqual({ "id-token": "write" });
52
52
  expect(props["runs-on"]).toBe("docker");
53
53
  expect(warnings.filter((w) => w.includes("permissions"))).toHaveLength(0);
54
54
  });
55
55
 
56
+ test("drops job permissions that carry no id-token, and the read-all shorthand", () => {
57
+ const job = new MockJob({
58
+ "runs-on": "ubuntu-latest",
59
+ permissions: { contents: "read", "pull-requests": "write" },
60
+ steps: [{ name: "a", run: "echo a" }],
61
+ });
62
+ const other = new MockJob({ "runs-on": "ubuntu-latest", permissions: "read-all" });
63
+ const { entities, warnings } = applyForgejoDialect(entityMap(["job", job], ["other", other]));
64
+ expect("permissions" in (entities.get("job") as MockJob).props).toBe(false);
65
+ expect("permissions" in (entities.get("other") as MockJob).props).toBe(false);
66
+ expect(warnings).toHaveLength(0);
67
+ });
68
+
69
+ test("leaves a step input named permissions alone", () => {
70
+ const job = new MockJob({
71
+ "runs-on": "ubuntu-latest",
72
+ steps: [{ uses: "some-org/custom-action@v1", with: { permissions: "everyone" } }],
73
+ });
74
+ const { entities } = applyForgejoDialect(entityMap(["job", job]));
75
+ const steps = (entities.get("job") as MockJob).props.steps as Array<{ with: Record<string, unknown> }>;
76
+ expect(steps[0].with.permissions).toBe("everyone");
77
+ });
78
+
56
79
  test("drops job and step continue-on-error, one warning each", () => {
57
80
  const job = new MockJob({
58
81
  "runs-on": "ubuntu-latest",
package/src/dialect.ts CHANGED
@@ -7,10 +7,13 @@
7
7
  * a pre-pass over the resolved entity graph before the github serializer runs:
8
8
  *
9
9
  * 1. `continue-on-error` is silently ignored by the Forgejo runner — we drop
10
- * it from the output and warn per occurrence. `permissions` is kept: a
11
- * Forgejo version that honours it (15 and later may) needs `id-token: write`
12
- * on a job to request an OIDC token, and an older runner ignores the key.
13
- * There is no version setting in the dialect, so the key always passes.
10
+ * it from the output and warn per occurrence. `permissions` is reduced to
11
+ * the one scope a Forgejo job can still use: `id-token`, which a job that
12
+ * assumes a cloud role over OIDC needs (#3500). Every other scope
13
+ * (`contents`, `pull-requests`, ...) is dropped without a warning, since
14
+ * Forgejo reads none of them and flags the key in the run page's
15
+ * "Workflow warnings" box. A `permissions` block with no `id-token` goes
16
+ * away whole; one with it keeps only that line.
14
17
  * 2. GitHub-hosted runner labels (`ubuntu-latest`, …) have no fixed meaning on
15
18
  * Forgejo — we map them to a default Forgejo label, overridable per project.
16
19
  * 3. Anything we can't place (an unmapped runner label) passes through with a
@@ -28,12 +31,40 @@ import { isDeclarable, isResourceDeclarable, type Declarable } from "@intentius/
28
31
  import { resolveActionRef } from "./actions";
29
32
 
30
33
  /**
31
- * Keys the Forgejo runner ignores. `permissions` is not among them: see above. Emitting them is misleading (they look
34
+ * Keys the Forgejo runner ignores. Emitting them is misleading (they look
32
35
  * enforced but aren't), so the dialect drops them. Compared in kebab-case so
33
36
  * both `continueOnError` and `continue-on-error` spellings are caught.
37
+ * `permissions` is handled apart, by {@link reducePermissions}.
34
38
  */
35
39
  const DROPPED_KEYS = new Set(["continue-on-error"]);
36
40
 
41
+ /** The one `permissions` scope kept: a job needs it to request an OIDC token. */
42
+ const KEPT_PERMISSION = "id-token";
43
+
44
+ const PERMISSION_LEVELS = new Set(["read", "write", "none"]);
45
+
46
+ /**
47
+ * True for a GitHub `permissions` value: `read-all`/`write-all`, or a map of
48
+ * scope to `read`/`write`/`none`. Anything else (an action's `with: {
49
+ * permissions: ... }` input, say) is not one and passes through untouched.
50
+ */
51
+ function isPermissionsValue(value: unknown): boolean {
52
+ if (typeof value === "string") return value === "read-all" || value === "write-all";
53
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
54
+ const entries = Object.values(value as Record<string, unknown>);
55
+ return entries.every((v) => typeof v === "string" && PERMISSION_LEVELS.has(v));
56
+ }
57
+
58
+ /**
59
+ * Reduce a `permissions` value to what Forgejo can use: the `id-token` scope,
60
+ * or `undefined` when there is none (the caller then omits the key).
61
+ */
62
+ function reducePermissions(value: unknown): Record<string, unknown> | undefined {
63
+ if (value === null || typeof value !== "object") return undefined;
64
+ const scope = (value as Record<string, unknown>)[KEPT_PERMISSION];
65
+ return scope === undefined ? undefined : { [KEPT_PERMISSION]: scope };
66
+ }
67
+
37
68
  /** Property key whose value is a runner-label selector. */
38
69
  const RUNS_ON_KEY = "runs-on";
39
70
 
@@ -117,6 +148,11 @@ function transformValue(value: unknown, ctx: TransformCtx): unknown {
117
148
  );
118
149
  continue;
119
150
  }
151
+ if (kebab === "permissions" && isPermissionsValue(v)) {
152
+ const kept = reducePermissions(v);
153
+ if (kept) result[key] = kept;
154
+ continue;
155
+ }
120
156
  if (kebab === RUNS_ON_KEY) {
121
157
  result[key] = remapRunsOn(v, ctx);
122
158
  continue;
@@ -4,8 +4,8 @@
4
4
  * github → forgejo YAML is near-identical, so the migration itself is thin. The
5
5
  * differentiated value is the **compare**: classifying what survives the move
6
6
  * and what Forgejo silently drops. Most properties translate verbatim; the
7
- * useful findings are the keys the Forgejo runner ignores (`permissions`,
8
- * `continue-on-error` → `lost`) plus refs/labels that need attention
7
+ * useful findings are the keys the Forgejo runner ignores (`permissions` →
8
+ * `needs-review`, `continue-on-error` → `lost`) plus refs/labels that need attention
9
9
  * (unresolved `uses:`, unmapped runner labels → `needs-review`).
10
10
  *
11
11
  * Mirrors the gitlab lexicon's fate model (translated / approximated /
@@ -80,8 +80,8 @@ export function analyzeForgejoSecurity(
80
80
  sourceFile: file,
81
81
  category: "needs-review",
82
82
  rule: "MIG-FJ-PERMISSIONS",
83
- note: `permissions: is kept, but only a Forgejo version that honours it enforces it (15 and later may). An older runner ignores it, so confirm the token scope in your Forgejo/runner and repository settings.`,
84
- security: { property: "Least-privilege permissions", fate: "needs-review", severity: "warning", reestablish: "Forgejo runner/token settings on a version that ignores permissions" },
83
+ note: `permissions: is dropped, except id-token, which a job needs to request an OIDC token. The Forgejo runner does not enforce the scopes, so confirm the token scope in your Forgejo/runner and repository settings.`,
84
+ security: { property: "Least-privilege permissions", fate: "needs-review", severity: "warning", reestablish: "Forgejo runner/token settings" },
85
85
  });
86
86
  continue;
87
87
  }
@@ -23,13 +23,31 @@ describe("github → forgejo transform", () => {
23
23
  test("emits forgejo YAML with the dialect applied", async () => {
24
24
  const { output } = await transform(GHA_WORKFLOW, { sourceFile: "ci.yml" });
25
25
  expect(output).toContain("runs-on: docker");
26
- expect(output).toContain("permissions");
26
+ expect(output).not.toContain("permissions");
27
27
  expect(output).not.toContain("continue-on-error");
28
28
  expect(output).toContain("https://code.forgejo.org/actions/checkout@v4");
29
29
  // unmapped ref still present (passed through), surfaced in the compare
30
30
  expect(output).toContain("some-org/custom-action@v1");
31
31
  });
32
32
 
33
+ test("keeps id-token from a job's permissions, for OIDC", async () => {
34
+ const { output } = await transform(
35
+ `on: push
36
+ jobs:
37
+ deploy:
38
+ runs-on: ubuntu-latest
39
+ permissions:
40
+ id-token: write
41
+ contents: read
42
+ steps:
43
+ - run: echo hi
44
+ `,
45
+ { sourceFile: "deploy.yml" },
46
+ );
47
+ expect(output).toContain("id-token: write");
48
+ expect(output).not.toContain("contents: read");
49
+ });
50
+
33
51
  test("classifies property fates (provenance + posture)", async () => {
34
52
  const { provenance, securityPosture, diagnostics } = await transform(GHA_WORKFLOW, { sourceFile: "ci.yml" });
35
53
  const rules = provenance.map((r) => r.rule);
@@ -47,9 +47,9 @@ describe("forgejoSerializer — github-style source roundtrip", () => {
47
47
  expect(primary).toContain("npm run build");
48
48
  });
49
49
 
50
- test("keeps permissions, drops continue-on-error with a warning on each", () => {
50
+ test("drops permissions silently, drops continue-on-error with a warning on each", () => {
51
51
  const result = asResult(forgejoSerializer.serialize(buildSource()));
52
- expect(result.primary).toContain("permissions");
52
+ expect(result.primary).not.toContain("permissions");
53
53
  expect(result.primary).not.toContain("continue-on-error");
54
54
  // job continue-on-error + step continue-on-error
55
55
  expect((result.warnings ?? []).filter((w) => w.includes("permissions"))).toHaveLength(0);
@@ -128,7 +128,7 @@ describe("forgejoSerializer — multi-workflow output", () => {
128
128
  // "@intentius/chant-lexicon-github"`); nothing forgejo-specific is written for
129
129
  // it. This is the functional check that inheritance actually holds, not just
130
130
  // that the type is importable: the dialect still drops the job's
131
- // `permissions` and remaps its runner label, and the sticky-comment step — a
131
+ // `permissions` (but for `id-token`) and remaps its runner label, and the sticky-comment step — a
132
132
  // plain `gh api` script with no `uses:` at all — passes through the dialect
133
133
  // transform untouched, because the dialect only ever rewrites job-level
134
134
  // `permissions:`/`environment:`/action refs and never looks at step content.
@@ -162,8 +162,8 @@ describe("forgejoSerializer — inherits PrPlanReport from github (#1983)", () =
162
162
  const result = asResult(out);
163
163
  expect(result.primary).toContain("runs-on: docker");
164
164
  expect(result.primary).not.toContain("ubuntu-latest");
165
- // The job carries `permissions`, which the dialect keeps.
166
- expect(result.primary).toContain("permissions:");
165
+ // The job carries `permissions` with no `id-token`, which the dialect drops.
166
+ expect(result.primary).not.toContain("permissions:");
167
167
  expect((result.warnings ?? []).filter((w) => w.includes("permissions"))).toHaveLength(0);
168
168
  // The sticky-comment mechanism is a raw script, nothing to remap or drop.
169
169
  expect(result.primary).toContain("Post or update PR comment");
@@ -38,8 +38,9 @@ chant build src -o .forgejo/workflows/ci.yml
38
38
 
39
39
  ## What the dialect does on build
40
40
 
41
- - **Drops keys the Forgejo runner ignores** — `permissions` and
42
- `continue-on-error` are removed (each emits a build warning).
41
+ - **Drops keys the Forgejo runner ignores** — `continue-on-error` is removed
42
+ with a build warning. `permissions` is removed silently, except `id-token`,
43
+ which stays for OIDC.
43
44
  - **Maps runner labels** — `ubuntu-latest` → `docker` by default; override via
44
45
  `forgejo.runnerLabels` in `chant.config.ts`. Unmapped labels warn.
45
46
  - **Resolves `uses:` refs** — common `actions/*` rewrite under
@@ -53,7 +54,7 @@ chant migrate .github/workflows/ci.yml --to forgejo -o .forgejo/workflows/ci.yml
53
54
  ```
54
55
 
55
56
  `--validate` prints a **Security posture** report: what survives the move and
56
- what Forgejo silently drops (`permissions`/`continue-on-error` → lost,
57
+ what Forgejo silently drops (`continue-on-error` → lost, `permissions` → needs-review,
57
58
  unresolved `uses:` / unmapped runner labels → needs-review). The same view is
58
59
  the `forgejo:compare` MCP tool.
59
60