@intentius/chant-lexicon-forgejo 0.4.0

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 ADDED
@@ -0,0 +1,116 @@
1
+ # @intentius/chant-lexicon-forgejo
2
+
3
+ Forgejo Actions lexicon for [chant](https://intentius.io/chant) — a **thin
4
+ dialect of the github lexicon**. Forgejo (the forge behind Codeberg,
5
+ self-hosted Forgejo, and Gitea) runs GitHub-Actions-compatible workflows, so
6
+ this package reuses the github lexicon's entities and composites wholesale and
7
+ overrides only what the dialect changes.
8
+
9
+ ## What it does
10
+
11
+ You author exactly as you would for GitHub Actions — same `Workflow`, `Job`,
12
+ `Step`, and composites, imported from `@intentius/chant-lexicon-forgejo`:
13
+
14
+ ```ts
15
+ import { Workflow, Job, Step, Checkout, SetupNode } from "@intentius/chant-lexicon-forgejo";
16
+
17
+ export const workflow = new Workflow({
18
+ name: "CI",
19
+ on: { push: { branches: ["main"] } },
20
+ });
21
+
22
+ export const build = new Job({
23
+ "runs-on": "ubuntu-latest",
24
+ steps: [
25
+ Checkout({}).step,
26
+ SetupNode({ nodeVersion: "22", cache: "npm" }).step,
27
+ new Step({ name: "Test", run: "npm test" }),
28
+ ],
29
+ });
30
+ ```
31
+
32
+ On build, the Forgejo dialect:
33
+
34
+ - **Drops keys Forgejo ignores** — `permissions` and `continue-on-error` are
35
+ silently ignored by the Forgejo runner, so they are removed from the output
36
+ and reported as build warnings (emitting them is misleading).
37
+ - **Maps runner labels** — GitHub-hosted labels like `ubuntu-latest` have no
38
+ fixed meaning on Forgejo. They are mapped to a default Forgejo label
39
+ (`docker`), overridable per project. Unmapped labels pass through with a
40
+ warning.
41
+ - **Resolves `uses:` action refs** — Forgejo has no GitHub Marketplace, so a
42
+ bare `uses: actions/checkout@v4` is rewritten to a resolvable form. Common
43
+ `actions/*` are mapped under an actions root (`https://code.forgejo.org` by
44
+ default, overridable via `forgejo.actionsRoot`); `docker/*` are pinned to
45
+ their full GitHub URL. Local (`./…`), `docker://`, and full-URL refs pass
46
+ through untouched. Anything else passes through **and is reported** as a
47
+ warning so it's never silently unresolvable.
48
+
49
+ Everything else is emitted by the github serializer, which already produces the
50
+ exact YAML shape Forgejo executes.
51
+
52
+ ## Building
53
+
54
+ Forgejo reads workflows from `.forgejo/workflows/` (and `.gitea/workflows/` for
55
+ Gitea), so point the build output there:
56
+
57
+ ```sh
58
+ chant build src -o .forgejo/workflows/ci.yml
59
+ ```
60
+
61
+ ## Migrating from GitHub Actions
62
+
63
+ Because github → forgejo YAML is near-identical, the migration is thin — it
64
+ applies the same dialect as a build. Its real value is the **compare**: what the
65
+ move costs.
66
+
67
+ ```sh
68
+ chant migrate .github/workflows/ci.yml --to forgejo -o .forgejo/workflows/ci.yml --validate
69
+ ```
70
+
71
+ `--validate` prints a **Security posture** report classifying each property's
72
+ fate across the edge:
73
+
74
+ | Fate | Meaning |
75
+ |---|---|
76
+ | `translated` | carried across as-is |
77
+ | `approximated` | carried with a close equivalent |
78
+ | `needs-review` | confirm/adjust on Forgejo (unresolved `uses:`, unmapped runner label) |
79
+ | `lost` | the Forgejo runner ignores it (`permissions`, `continue-on-error`) |
80
+
81
+ The same view is available to agents as the `forgejo:compare` MCP tool, which
82
+ takes a workflow file and returns per-property fates plus summary counts —
83
+ read-only.
84
+
85
+ > Note: flow-style YAML (`branches: [main]`) is parsed by chant's lightweight
86
+ > core parser, which keeps it as a scalar; prefer block style in sources you
87
+ > intend to migrate. This is shared with the github import path.
88
+
89
+ ## Configuration
90
+
91
+ Override runner-label mapping in `chant.config.ts`:
92
+
93
+ ```ts
94
+ import type { ChantConfig } from "@intentius/chant";
95
+
96
+ export default {
97
+ lexicons: ["forgejo"],
98
+ forgejo: {
99
+ runnerLabels: {
100
+ "ubuntu-latest": "docker",
101
+ "ubuntu-22.04": "ubuntu-lts",
102
+ },
103
+ // Base for resolving mirrored `uses:` action refs.
104
+ actionsRoot: "https://code.forgejo.org",
105
+ },
106
+ } satisfies ChantConfig;
107
+ ```
108
+
109
+ ## Notes
110
+
111
+ - The serializer's lexicon `name` is `"github"` on purpose: it serializes the
112
+ reused github-lexicon entities (which are tagged `lexicon: "github"`). Its
113
+ rule prefix is `WFJ` to keep Forgejo diagnostics namespaced apart from
114
+ github's `GHA`.
115
+ - No codegen: this lexicon has no spec of its own. Run `chant generate` in the
116
+ github lexicon if you need to refresh entities.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@intentius/chant-lexicon-forgejo",
3
+ "version": "0.4.0",
4
+ "description": "Forgejo / Codeberg / Gitea Actions lexicon for chant — a thin GitHub Actions dialect",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://intentius.io/chant",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/INTENTIUS/chant.git",
10
+ "directory": "lexicons/forgejo"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/INTENTIUS/chant/issues"
14
+ },
15
+ "keywords": [
16
+ "infrastructure-as-code",
17
+ "iac",
18
+ "typescript",
19
+ "forgejo",
20
+ "codeberg",
21
+ "gitea",
22
+ "actions",
23
+ "chant"
24
+ ],
25
+ "type": "module",
26
+ "files": [
27
+ "src/",
28
+ "dist/"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "exports": {
34
+ ".": "./src/index.ts",
35
+ "./*": "./src/*.ts"
36
+ },
37
+ "devDependencies": {
38
+ "@intentius/chant": "*",
39
+ "@intentius/chant-lexicon-github": "*",
40
+ "typescript": "^5.9.3"
41
+ },
42
+ "peerDependencies": {
43
+ "@intentius/chant": "^0.1.0",
44
+ "@intentius/chant-lexicon-github": "^0.1.0"
45
+ }
46
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { resolveActionRef, DEFAULT_ACTIONS_ROOT } from "./actions";
3
+
4
+ describe("resolveActionRef — mapped actions", () => {
5
+ test("rewrites actions/checkout@v4 under the default actions root", () => {
6
+ const { rewritten, warning } = resolveActionRef("actions/checkout@v4");
7
+ expect(rewritten).toBe(`${DEFAULT_ACTIONS_ROOT}/actions/checkout@v4`);
8
+ expect(warning).toBeUndefined();
9
+ });
10
+
11
+ test("rewrites the common actions/* set", () => {
12
+ for (const name of [
13
+ "actions/setup-node",
14
+ "actions/setup-go",
15
+ "actions/setup-python",
16
+ "actions/cache",
17
+ "actions/upload-artifact",
18
+ "actions/download-artifact",
19
+ ]) {
20
+ const { rewritten, warning } = resolveActionRef(`${name}@v4`);
21
+ expect(rewritten).toBe(`${DEFAULT_ACTIONS_ROOT}/${name}@v4`);
22
+ expect(warning).toBeUndefined();
23
+ }
24
+ });
25
+
26
+ test("pins docker/* to a full GitHub URL, independent of the actions root", () => {
27
+ const { rewritten, warning } = resolveActionRef("docker/build-push-action@v5", {
28
+ actionsRoot: "https://example.test",
29
+ });
30
+ expect(rewritten).toBe("https://github.com/docker/build-push-action@v5");
31
+ expect(warning).toBeUndefined();
32
+ });
33
+
34
+ test("preserves a subpath", () => {
35
+ const { rewritten } = resolveActionRef("actions/cache/restore@v4");
36
+ expect(rewritten).toBe(`${DEFAULT_ACTIONS_ROOT}/actions/cache/restore@v4`);
37
+ });
38
+
39
+ test("handles a ref with no version", () => {
40
+ const { rewritten } = resolveActionRef("actions/checkout");
41
+ expect(rewritten).toBe(`${DEFAULT_ACTIONS_ROOT}/actions/checkout`);
42
+ });
43
+ });
44
+
45
+ describe("resolveActionRef — actionsRoot override", () => {
46
+ test("override changes the base for mirrored actions", () => {
47
+ const { rewritten } = resolveActionRef("actions/checkout@v4", {
48
+ actionsRoot: "https://codeberg.org",
49
+ });
50
+ expect(rewritten).toBe("https://codeberg.org/actions/checkout@v4");
51
+ });
52
+
53
+ test("a trailing slash on the root is normalized", () => {
54
+ const { rewritten } = resolveActionRef("actions/checkout@v4", {
55
+ actionsRoot: "https://codeberg.org/",
56
+ });
57
+ expect(rewritten).toBe("https://codeberg.org/actions/checkout@v4");
58
+ });
59
+ });
60
+
61
+ describe("resolveActionRef — unmapped refs", () => {
62
+ test("passes an unmapped owner/repo through and reports it", () => {
63
+ const { rewritten, warning } = resolveActionRef("some-org/custom-action@v1");
64
+ expect(rewritten).toBe("some-org/custom-action@v1");
65
+ expect(warning).toBeDefined();
66
+ expect(warning).toContain("some-org/custom-action@v1");
67
+ });
68
+ });
69
+
70
+ describe("resolveActionRef — already-resolvable refs", () => {
71
+ test.each([
72
+ "./.forgejo/actions/local",
73
+ "../shared/action",
74
+ "docker://alpine:3.20",
75
+ "https://codeberg.org/actions/checkout@v4",
76
+ "http://example.test/x@v1",
77
+ ])("leaves %s untouched without warning", (ref) => {
78
+ const { rewritten, warning } = resolveActionRef(ref);
79
+ expect(rewritten).toBe(ref);
80
+ expect(warning).toBeUndefined();
81
+ });
82
+ });
package/src/actions.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Forgejo `uses:` action-reference resolver.
3
+ *
4
+ * GitHub Actions resolves a bare `uses: actions/checkout@v4` against the GitHub
5
+ * Marketplace. Forgejo / Codeberg / Gitea runners have no Marketplace, so such a
6
+ * ref must be rewritten to a form their runner can fetch — a full repository URL,
7
+ * or a path under a configured actions root that mirrors the action.
8
+ *
9
+ * The resolver rewrites a built-in set of common actions and reports anything it
10
+ * can't place as a diagnostic (never dropping or silently passing it through).
11
+ */
12
+
13
+ /**
14
+ * Default actions root. `code.forgejo.org` hosts the Forgejo `actions/*` mirror
15
+ * (checkout, setup-*, cache, upload/download-artifact, …). Override per project
16
+ * via `forgejo.actionsRoot` in `chant.config.ts`.
17
+ */
18
+ export const DEFAULT_ACTIONS_ROOT = "https://code.forgejo.org";
19
+
20
+ /**
21
+ * Where a known action resolves:
22
+ * - `mirror`: `<actionsRoot>/<repo>` — follows the configured actions root.
23
+ * - `url`: an absolute base URL — independent of the actions root (used for
24
+ * actions not carried by the Forgejo mirror, e.g. `docker/*`).
25
+ */
26
+ export type ActionTarget = { kind: "mirror"; repo: string } | { kind: "url"; url: string };
27
+
28
+ /** Built-in mapping of GitHub action `owner/repo` → Forgejo-resolvable target. */
29
+ export const KNOWN_ACTIONS: Record<string, ActionTarget> = {
30
+ // actions/* — mirrored on the Forgejo actions root.
31
+ "actions/checkout": { kind: "mirror", repo: "actions/checkout" },
32
+ "actions/setup-node": { kind: "mirror", repo: "actions/setup-node" },
33
+ "actions/setup-go": { kind: "mirror", repo: "actions/setup-go" },
34
+ "actions/setup-python": { kind: "mirror", repo: "actions/setup-python" },
35
+ "actions/setup-java": { kind: "mirror", repo: "actions/setup-java" },
36
+ "actions/setup-dotnet": { kind: "mirror", repo: "actions/setup-dotnet" },
37
+ "actions/cache": { kind: "mirror", repo: "actions/cache" },
38
+ "actions/upload-artifact": { kind: "mirror", repo: "actions/upload-artifact" },
39
+ "actions/download-artifact": { kind: "mirror", repo: "actions/download-artifact" },
40
+ // docker/* — not on the Forgejo mirror; pin to the full GitHub URL, which
41
+ // Forgejo runners can fetch directly.
42
+ "docker/build-push-action": { kind: "url", url: "https://github.com/docker/build-push-action" },
43
+ "docker/login-action": { kind: "url", url: "https://github.com/docker/login-action" },
44
+ "docker/setup-buildx-action": { kind: "url", url: "https://github.com/docker/setup-buildx-action" },
45
+ "docker/setup-qemu-action": { kind: "url", url: "https://github.com/docker/setup-qemu-action" },
46
+ "docker/metadata-action": { kind: "url", url: "https://github.com/docker/metadata-action" },
47
+ };
48
+
49
+ export interface ActionResolveOptions {
50
+ /** Base for `mirror` targets; defaults to {@link DEFAULT_ACTIONS_ROOT}. */
51
+ actionsRoot?: string;
52
+ /** Mapping table to use; defaults to {@link KNOWN_ACTIONS}. */
53
+ known?: Record<string, ActionTarget>;
54
+ }
55
+
56
+ export interface ActionResolveResult {
57
+ /** The rewritten (or unchanged) `uses:` ref. */
58
+ rewritten: string;
59
+ /** Set when the ref could not be resolved — surfaced as a build warning. */
60
+ warning?: string;
61
+ }
62
+
63
+ /** A ref that already resolves on Forgejo as-is needs no rewrite and no warning. */
64
+ function isAlreadyResolvable(ref: string): boolean {
65
+ return (
66
+ ref.startsWith("./") ||
67
+ ref.startsWith("../") ||
68
+ ref.startsWith(".\\") ||
69
+ ref.startsWith("docker://") ||
70
+ /^https?:\/\//.test(ref)
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Resolve a single `uses:` action ref to a Forgejo-resolvable form.
76
+ */
77
+ export function resolveActionRef(ref: string, options: ActionResolveOptions = {}): ActionResolveResult {
78
+ const trimmed = ref.trim();
79
+ if (trimmed === "" || isAlreadyResolvable(trimmed)) return { rewritten: ref };
80
+
81
+ const root = (options.actionsRoot ?? DEFAULT_ACTIONS_ROOT).replace(/\/+$/, "");
82
+ const known = options.known ?? KNOWN_ACTIONS;
83
+
84
+ const atIndex = trimmed.lastIndexOf("@");
85
+ const path = atIndex >= 0 ? trimmed.slice(0, atIndex) : trimmed;
86
+ const version = atIndex >= 0 ? trimmed.slice(atIndex + 1) : "";
87
+
88
+ const segments = path.split("/");
89
+ if (segments.length < 2) {
90
+ return { rewritten: ref, warning: unresolvedWarning(trimmed) };
91
+ }
92
+
93
+ const actionName = `${segments[0]}/${segments[1]}`;
94
+ const subpath = segments.slice(2).join("/");
95
+ const target = known[actionName];
96
+ if (!target) {
97
+ return { rewritten: ref, warning: unresolvedWarning(trimmed) };
98
+ }
99
+
100
+ const base = target.kind === "mirror" ? `${root}/${target.repo}` : target.url;
101
+ const withSubpath = subpath ? `${base}/${subpath}` : base;
102
+ const rewritten = version ? `${withSubpath}@${version}` : withSubpath;
103
+ return { rewritten };
104
+ }
105
+
106
+ function unresolvedWarning(ref: string): string {
107
+ return (
108
+ `forgejo: unresolved action ref '${ref}' — it has no built-in mapping and won't ` +
109
+ `resolve from a GitHub Marketplace on Forgejo. Use a full repository URL, or mirror ` +
110
+ `it under forgejo.actionsRoot in chant.config.ts.`
111
+ );
112
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
3
+ import { applyForgejoDialect, DEFAULT_RUNNER_LABELS } from "./dialect";
4
+
5
+ // ── Mock entities (github-tagged, as the dialect reuses github entities) ──
6
+
7
+ class MockJob implements Declarable {
8
+ readonly [DECLARABLE_MARKER] = true as const;
9
+ readonly lexicon = "github";
10
+ readonly entityType = "GitHub::Actions::Job";
11
+ readonly kind = "resource" as const;
12
+ readonly props: Record<string, unknown>;
13
+ constructor(props: Record<string, unknown> = {}) {
14
+ this.props = props;
15
+ }
16
+ }
17
+
18
+ class MockWorkflow implements Declarable {
19
+ readonly [DECLARABLE_MARKER] = true as const;
20
+ readonly lexicon = "github";
21
+ readonly entityType = "GitHub::Actions::Workflow";
22
+ readonly kind = "resource" as const;
23
+ readonly props: Record<string, unknown>;
24
+ constructor(props: Record<string, unknown> = {}) {
25
+ this.props = props;
26
+ }
27
+ }
28
+
29
+ function entityMap(...pairs: Array<[string, Declarable]>): Map<string, Declarable> {
30
+ return new Map(pairs);
31
+ }
32
+
33
+ describe("applyForgejoDialect — dropped keys", () => {
34
+ test("drops workflow-level permissions and warns", () => {
35
+ const wf = new MockWorkflow({ name: "CI", permissions: { contents: "read" } });
36
+ const { entities, warnings } = applyForgejoDialect(entityMap(["workflow", wf]));
37
+ const props = (entities.get("workflow") as MockWorkflow).props;
38
+ expect(props.permissions).toBeUndefined();
39
+ expect(props.name).toBe("CI");
40
+ expect(warnings.filter((w) => w.includes("permissions"))).toHaveLength(1);
41
+ });
42
+
43
+ test("drops job and step continue-on-error, one warning each", () => {
44
+ const job = new MockJob({
45
+ "runs-on": "ubuntu-latest",
46
+ "continue-on-error": true,
47
+ steps: [
48
+ { name: "a", run: "echo a", "continue-on-error": true },
49
+ { name: "b", run: "echo b" },
50
+ ],
51
+ });
52
+ const { entities, warnings } = applyForgejoDialect(entityMap(["build", job]));
53
+ const props = (entities.get("build") as MockJob).props;
54
+ expect(props["continue-on-error"]).toBeUndefined();
55
+ const steps = props.steps as Array<Record<string, unknown>>;
56
+ expect(steps[0]["continue-on-error"]).toBeUndefined();
57
+ expect(steps[0].name).toBe("a");
58
+ expect(warnings.filter((w) => w.includes("continue-on-error"))).toHaveLength(2);
59
+ });
60
+
61
+ test("also catches the camelCase spelling (continueOnError)", () => {
62
+ const job = new MockJob({ continueOnError: true, "runs-on": "ubuntu-latest" });
63
+ const { entities, warnings } = applyForgejoDialect(entityMap(["build", job]));
64
+ const props = (entities.get("build") as MockJob).props;
65
+ expect(props.continueOnError).toBeUndefined();
66
+ expect(warnings.filter((w) => w.includes("continue-on-error"))).toHaveLength(1);
67
+ });
68
+
69
+ test("does not mutate the original entity", () => {
70
+ const wf = new MockWorkflow({ name: "CI", permissions: { contents: "read" } });
71
+ applyForgejoDialect(entityMap(["workflow", wf]));
72
+ expect(wf.props.permissions).toEqual({ contents: "read" });
73
+ });
74
+ });
75
+
76
+ describe("applyForgejoDialect — runner labels", () => {
77
+ test("maps ubuntu-latest to the default Forgejo label", () => {
78
+ const job = new MockJob({ "runs-on": "ubuntu-latest" });
79
+ const { entities, warnings } = applyForgejoDialect(entityMap(["build", job]));
80
+ expect((entities.get("build") as MockJob).props["runs-on"]).toBe(DEFAULT_RUNNER_LABELS["ubuntu-latest"]);
81
+ expect(warnings).toHaveLength(0);
82
+ });
83
+
84
+ test("a project override changes the emitted label", () => {
85
+ const job = new MockJob({ "runs-on": "ubuntu-latest" });
86
+ const { entities } = applyForgejoDialect(entityMap(["build", job]), {
87
+ runnerLabels: { "ubuntu-latest": "big-runner" },
88
+ });
89
+ expect((entities.get("build") as MockJob).props["runs-on"]).toBe("big-runner");
90
+ });
91
+
92
+ test("an unmapped label passes through unchanged (reported by WFJ011, not here)", () => {
93
+ const job = new MockJob({ "runs-on": "macos-latest" });
94
+ const { entities, warnings } = applyForgejoDialect(entityMap(["build", job]));
95
+ expect((entities.get("build") as MockJob).props["runs-on"]).toBe("macos-latest");
96
+ expect(warnings.filter((w) => w.includes("macos-latest"))).toHaveLength(0);
97
+ });
98
+
99
+ test("remaps every label in an array form, leaving unmapped ones untouched", () => {
100
+ const job = new MockJob({ "runs-on": ["ubuntu-latest", "self-hosted"] });
101
+ const { entities } = applyForgejoDialect(entityMap(["build", job]));
102
+ expect((entities.get("build") as MockJob).props["runs-on"]).toEqual(["docker", "self-hosted"]);
103
+ });
104
+ });
package/src/dialect.ts ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Forgejo dialect transform.
3
+ *
4
+ * Forgejo Actions (the engine behind Codeberg, self-hosted Forgejo, and Gitea)
5
+ * runs GitHub-Actions-compatible YAML, so the github lexicon's serializer emits
6
+ * the right shape. The dialect differs in three small ways, all handled here as
7
+ * a pre-pass over the resolved entity graph before the github serializer runs:
8
+ *
9
+ * 1. `permissions` and `continue-on-error` are silently ignored by the Forgejo
10
+ * runner — we drop them from the output and warn per occurrence.
11
+ * 2. GitHub-hosted runner labels (`ubuntu-latest`, …) have no fixed meaning on
12
+ * Forgejo — we map them to a default Forgejo label, overridable per project.
13
+ * 3. Anything we can't place (an unmapped runner label) passes through with a
14
+ * warning rather than being dropped.
15
+ *
16
+ * Operating on the entity graph (rather than string-munging YAML) keeps the
17
+ * transform faithful: the github serializer still does the actual emission.
18
+ */
19
+
20
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
21
+ import { resolveActionRef } from "./actions";
22
+
23
+ /**
24
+ * Keys the Forgejo runner ignores. Emitting them is misleading (they look
25
+ * enforced but aren't), so the dialect drops them. Compared in kebab-case so
26
+ * both `continueOnError` and `continue-on-error` spellings are caught.
27
+ */
28
+ const DROPPED_KEYS = new Set(["permissions", "continue-on-error"]);
29
+
30
+ /** Property key whose value is a runner-label selector. */
31
+ const RUNS_ON_KEY = "runs-on";
32
+
33
+ /** Property key whose value is an action/workflow reference. */
34
+ const USES_KEY = "uses";
35
+
36
+ /**
37
+ * Default GitHub-hosted runner label → Forgejo label mapping. `docker` is the
38
+ * label a freshly-registered Forgejo `act_runner` exposes, and the one used
39
+ * throughout the Forgejo Actions docs, so it is the safest default target.
40
+ * Override per project via `forgejo.runnerLabels` in `chant.config.ts`.
41
+ */
42
+ export const DEFAULT_RUNNER_LABELS: Record<string, string> = {
43
+ "ubuntu-latest": "docker",
44
+ "ubuntu-24.04": "docker",
45
+ "ubuntu-22.04": "docker",
46
+ "ubuntu-20.04": "docker",
47
+ };
48
+
49
+ export interface ForgejoDialectOptions {
50
+ /** Project-supplied label overrides, merged over {@link DEFAULT_RUNNER_LABELS}. */
51
+ runnerLabels?: Record<string, string>;
52
+ /** Base for resolving mirrored `uses:` action refs (see ./actions). */
53
+ actionsRoot?: string;
54
+ }
55
+
56
+ export interface ForgejoDialectResult {
57
+ /** The transformed entity map (clones; originals are not mutated). */
58
+ entities: Map<string, Declarable>;
59
+ /** One warning per dropped key and per unmapped runner label. */
60
+ warnings: string[];
61
+ }
62
+
63
+ /** Convert a camelCase or kebab-case key to a canonical kebab-case form. */
64
+ function toKebabCase(name: string): string {
65
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
66
+ }
67
+
68
+ function isDeclarable(value: unknown): value is Declarable {
69
+ return typeof value === "object" && value !== null && DECLARABLE_MARKER in value;
70
+ }
71
+
72
+ interface TransformCtx {
73
+ labels: Record<string, string>;
74
+ actionsRoot?: string;
75
+ warnings: string[];
76
+ /** Human-readable location used in warning messages. */
77
+ where: string;
78
+ }
79
+
80
+ /**
81
+ * Map a single runner label, passing it through unchanged when unmapped. An
82
+ * unmapped label that survives into the output is reported by the WFJ011
83
+ * post-synth check (which surfaces in both `chant build` and `chant lint`),
84
+ * so the dialect doesn't also warn here — that would double-report.
85
+ */
86
+ function mapLabel(label: string, ctx: TransformCtx): string {
87
+ return ctx.labels[label] ?? label;
88
+ }
89
+
90
+ /** Remap a `runs-on` value (string or string[]); leave other shapes untouched. */
91
+ function remapRunsOn(value: unknown, ctx: TransformCtx): unknown {
92
+ if (typeof value === "string") return mapLabel(value, ctx);
93
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
94
+ return (value as string[]).map((v) => mapLabel(v, ctx));
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function transformValue(value: unknown, ctx: TransformCtx): unknown {
100
+ if (value === null || typeof value !== "object") return value;
101
+
102
+ if (isDeclarable(value)) return cloneDeclarable(value, ctx);
103
+
104
+ if (Array.isArray(value)) return value.map((v) => transformValue(v, ctx));
105
+
106
+ // Plain object: drop ignored keys, remap runs-on, recurse into the rest.
107
+ const result: Record<string, unknown> = {};
108
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
109
+ const kebab = toKebabCase(key);
110
+ if (DROPPED_KEYS.has(kebab)) {
111
+ ctx.warnings.push(
112
+ `forgejo: dropped '${kebab}' in ${ctx.where} — the Forgejo runner ignores it. ` +
113
+ `Re-establish this control through your Forgejo/runner configuration.`,
114
+ );
115
+ continue;
116
+ }
117
+ if (kebab === RUNS_ON_KEY) {
118
+ result[key] = remapRunsOn(v, ctx);
119
+ continue;
120
+ }
121
+ if (kebab === USES_KEY && typeof v === "string") {
122
+ // Rewrite to a Forgejo-resolvable form. An unresolved ref that survives
123
+ // is reported by the WFJ010 post-synth check (build + lint), so the
124
+ // dialect doesn't also warn here — that would double-report.
125
+ result[key] = resolveActionRef(v, { actionsRoot: ctx.actionsRoot }).rewritten;
126
+ continue;
127
+ }
128
+ result[key] = transformValue(v, ctx);
129
+ }
130
+ return result;
131
+ }
132
+
133
+ /** Shallow-clone a Declarable, replacing its `props` with a transformed copy. */
134
+ function cloneDeclarable(entity: Declarable, ctx: TransformCtx): Declarable {
135
+ const descriptors = Object.getOwnPropertyDescriptors(entity);
136
+ const clone = Object.create(Object.getPrototypeOf(entity), descriptors) as Declarable;
137
+ const rawProps = (entity as unknown as { props?: unknown }).props;
138
+ const newProps = transformValue(rawProps, ctx);
139
+ Object.defineProperty(clone, "props", {
140
+ value: newProps,
141
+ enumerable: descriptors.props?.enumerable ?? false,
142
+ configurable: true,
143
+ writable: descriptors.props?.writable ?? true,
144
+ });
145
+ return clone;
146
+ }
147
+
148
+ export interface TransformObjectResult {
149
+ /** The transformed plain value (clone; the input is not mutated). */
150
+ value: unknown;
151
+ /** One warning per dropped key, unmapped label, and unresolved action ref. */
152
+ warnings: string[];
153
+ }
154
+
155
+ /**
156
+ * Apply the Forgejo dialect to a plain object — e.g. a parsed GitHub Actions
157
+ * workflow during migration, rather than the resolved entity graph. Same
158
+ * rules as {@link applyForgejoDialect}: drop ignored keys, remap runner
159
+ * labels, resolve `uses:` refs.
160
+ */
161
+ export function transformWorkflowObject(
162
+ obj: unknown,
163
+ options: ForgejoDialectOptions = {},
164
+ ): TransformObjectResult {
165
+ const labels = { ...DEFAULT_RUNNER_LABELS, ...(options.runnerLabels ?? {}) };
166
+ const warnings: string[] = [];
167
+ const ctx: TransformCtx = { labels, actionsRoot: options.actionsRoot, warnings, where: "workflow" };
168
+ return { value: transformValue(obj, ctx), warnings };
169
+ }
170
+
171
+ /**
172
+ * Apply the Forgejo dialect to a resolved entity map. Returns transformed
173
+ * clones plus the diagnostics produced (dropped keys, unmapped labels).
174
+ */
175
+ export function applyForgejoDialect(
176
+ entities: Map<string, Declarable>,
177
+ options: ForgejoDialectOptions = {},
178
+ ): ForgejoDialectResult {
179
+ const labels = { ...DEFAULT_RUNNER_LABELS, ...(options.runnerLabels ?? {}) };
180
+ const warnings: string[] = [];
181
+ const out = new Map<string, Declarable>();
182
+
183
+ for (const [name, entity] of entities) {
184
+ const ctx: TransformCtx = { labels, actionsRoot: options.actionsRoot, warnings, where: name };
185
+ out.set(name, cloneDeclarable(entity, ctx));
186
+ }
187
+
188
+ return { entities: out, warnings };
189
+ }