@intentius/chant-lexicon-github 0.124.13 → 0.124.15

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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The GitHub Actions job pieces the three generators share (#3765): the Op
3
+ * pipeline (`./generate-op-pipeline.ts`), the Op waves pipeline
4
+ * (`./generate-op-waves-pipeline.ts`) and the component pipeline
5
+ * (`./generate-pipeline.ts`). The forgejo lexicon builds on the same
6
+ * documents and applies its dialect. The forge-neutral rules (image default,
7
+ * branch defaults, tokens, permissions, setup and environment checks) are
8
+ * core's, in `@intentius/chant/ci-jobs`.
9
+ */
10
+ import type { OpTrigger } from "@intentius/chant/lexicon";
11
+ /** The artifact actions every generated job uses, at the major the generators have always pinned. */
12
+ export declare const UPLOAD_ARTIFACT = "actions/upload-artifact@v4";
13
+ export declare const DOWNLOAD_ARTIFACT = "actions/download-artifact@v4";
14
+ /** The checkout step; `fullHistory` fetches every commit, for a job that reads the base commit or measures a change with git. */
15
+ export declare function checkoutStep(fullHistory?: boolean): Record<string, unknown>;
16
+ /** One `run:` step per line, so each line is its own step in the log. */
17
+ export declare function runSteps(lines: readonly string[]): Array<Record<string, unknown>>;
18
+ /** A step that downloads `artifact` into `path`. */
19
+ export declare function downloadArtifactStep(name: string, artifact: string, path: string): Record<string, unknown>;
20
+ /**
21
+ * A step that keeps a record as an artifact whatever the job's outcome, so a
22
+ * job that stops at a gate (exit 3) still hands its record on, and a re-run
23
+ * replaces it. Nothing to keep is not an error.
24
+ */
25
+ export declare function keepRecordStep(name: string, artifact: string, path: string): Record<string, unknown>;
26
+ /** A `concurrency:` mapping: one run at a time per group. */
27
+ export declare function concurrency(group: string, cancelInProgress?: boolean): Record<string, unknown>;
28
+ /** The job token, as a step's `GITHUB_TOKEN`; `gh` reads `GH_TOKEN` too, for a step that writes. */
29
+ export declare function jobTokenEnv(gh?: boolean): Record<string, string>;
30
+ /**
31
+ * A trigger's `on:` mapping (#2084): `cron` runs on its schedule and by hand,
32
+ * `pull_request` on pull requests into its branches (every branch when it
33
+ * names none; no `workflow_dispatch`, since a PR event needs no manual
34
+ * escape hatch), `push` on pushes to its branches (core's default when it
35
+ * names none).
36
+ */
37
+ export declare function onTrigger(trigger: OpTrigger): Record<string, unknown>;
38
+ /**
39
+ * A workflow document before emission. Every generator's document is one of
40
+ * these; each leaves out the sections it has no use for.
41
+ */
42
+ export interface GithubWorkflowDoc {
43
+ /** Comment lines above the document, `#` included: what a dialect could not carry across (#2257). */
44
+ header?: string[];
45
+ /** The workflow's `name:`. */
46
+ name?: string;
47
+ /** The `on:` trigger mapping. */
48
+ on: Record<string, unknown>;
49
+ /** The workflow-level `env:` mapping, emitted when it has an entry. */
50
+ env?: Record<string, unknown>;
51
+ /** The workflow's `defaults:`. */
52
+ defaults?: Record<string, unknown>;
53
+ /** The `concurrency:` mapping. */
54
+ concurrency?: Record<string, unknown>;
55
+ /** The workflow-level `permissions:` mapping, emitted when it has an entry. */
56
+ permissions?: Record<string, unknown>;
57
+ /** The `jobs:` mapping. */
58
+ jobsDoc: Record<string, unknown>;
59
+ }
60
+ /** Emit a workflow document as YAML, its sections in GitHub's customary order. */
61
+ export declare function emitWorkflowYAML(doc: GithubWorkflowDoc): string;
62
+ //# sourceMappingURL=ci-jobs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci-jobs.d.ts","sourceRoot":"","sources":["../../src/components/ci-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAG1D,qGAAqG;AACrG,eAAO,MAAM,eAAe,+BAA+B,CAAC;AAC5D,eAAO,MAAM,iBAAiB,iCAAiC,CAAC;AAEhE,iIAAiI;AACjI,wBAAgB,YAAY,CAAC,WAAW,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,yEAAyE;AACzE,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAEjF;AAED,oDAAoD;AACpD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE1G;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOpG;AAED,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,gBAAgB,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE5F;AAED,oGAAoG;AACpG,wBAAgB,WAAW,CAAC,EAAE,UAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAE9D;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASrE;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,qGAAqG;IACrG,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8BAA8B;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iCAAiC;IACjC,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,kCAAkC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,kCAAkC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,kFAAkF;AAClF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAW/D"}
@@ -69,8 +69,10 @@
69
69
  * trigger, the gate-notice job beside it — job-level `env:` does not reach
70
70
  * that job either, same as `environment:` above does not).
71
71
  */
72
- import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpEnvironment, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
72
+ import { assertEnvironment, assertSetupSteps, mergePermissions } from "@intentius/chant/ci-jobs";
73
+ import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
73
74
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
75
+ export { assertEnvironment, assertSetupSteps, mergePermissions };
74
76
  /**
75
77
  * The structured pipeline document behind one generated file, before YAML
76
78
  * emission — exposed so a GitHub-Actions dialect (the forgejo lexicon, #969)
@@ -129,50 +131,6 @@ export interface GithubOpPipelineFile {
129
131
  name: string;
130
132
  doc: GithubOpPipelineDoc;
131
133
  }
132
- /**
133
- * Validate a spec's `setup` list (#2242). A `run` entry needs a non-empty
134
- * line and nothing else. A `uses` entry has to be a pinned
135
- * `owner/repo[/subpath]@ref`: no bare `owner/repo`, since an unpinned action
136
- * resolves to its default branch, and no ref in {@link DEFAULT_BRANCH_REFS}
137
- * for the same reason spelled out loud. Local (`./path`) and container
138
- * (`docker://`) refs are refused too — they are legal GitHub Actions, but the
139
- * generator emits a workflow into a repository it has never seen, so it
140
- * cannot know a local path resolves there.
141
- */
142
- export declare function assertSetupSteps(name: string, setup: OpSetupStep[]): void;
143
- /**
144
- * Validate a spec's `environment` (#2257). GitHub creates an environment it
145
- * has never seen on first use rather than failing the run, and an environment
146
- * created that way carries no protection rules at all — so a job can name one
147
- * and read as gated while being gated by nothing. Neither this generator nor
148
- * GitHub can tell those apart at build time (the environment and its
149
- * reviewers are repository configuration, not workflow content), which is why
150
- * what is refused here is only the shape that could never bind: a name that
151
- * is blank, and a `url` that is neither absolute nor an expression the forge
152
- * resolves. The rest is the README's job to say out loud.
153
- */
154
- export declare function assertEnvironment(name: string, environment: OpEnvironment): void;
155
- /**
156
- * Merge a spec's additive `permissions` over the finding-mode's own set
157
- * (#2242), refusing by name anything that is not strictly additive:
158
- *
159
- * - a blanket `write-all`/`read-all`, in either the key or the value
160
- * position, which is the exact thing {@link permissionsForMode} exists to
161
- * avoid;
162
- * - a scope GitHub does not define ({@link GITHUB_TOKEN_SCOPES}), because
163
- * GitHub ignores the key and the run silently gets nothing;
164
- * - a scope the mode already grants, at any value — additive means additive,
165
- * so this can neither downgrade `contents: write` to read nor restate it.
166
- * A mode whose set is wrong is fixed by changing the mode, where the
167
- * scope and the behavior that spends it stay together;
168
- * - `pull-requests: write` on a trigger with no pull request. Pull-request
169
- * access is what the finding-modes own: `pull-request` grants it together
170
- * with the `contents: write` needed to push the branch first, and
171
- * `comment` grants it on the one trigger that carries a pull request to
172
- * comment on. Adding it beside a mode that posts nothing, on a cron or
173
- * push run, grants write access no step in the generated job can spend.
174
- */
175
- export declare function mergePermissions(name: string, base: Record<string, "read" | "write">, additive: Record<string, "read" | "write">, trigger: OpTrigger): Record<string, "read" | "write">;
176
134
  /**
177
135
  * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
178
136
  * steps, least-privilege `permissions:` for its finding-mode plus whatever
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAKH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EAET,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAgVD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAiG1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAOhB,gBAAgB,EAIjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAG1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAIhE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAuOD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CA8F1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAMnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAqD,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AACnH,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,wBAAwB,CAAC;AAEhC,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAY,CAAC;AAExC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAOD,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAkEpI;AA4CD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAqD,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AACnH,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAe9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAgDpI;AAyCD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAKjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAqMD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+KnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAU/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAMjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAsKD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "ac146d6030ea7fc4152c7f17bfa7ceee1190e581e57838415efdc9f41860efee",
4
+ "manifest.json": "b8a0f2d2359a9a92f639124f22b9219da0da2625af176de03ea56331cab26215",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
7
7
  "rules/deprecated-action-version.ts": "a9ba7cc924ff1c7055110416d8989273690415bfb5e8dcc32dc9431b9351f5ca",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "51cd66e7c237243e7153d158f2b1c4ee2205d325ed12c270512dcbe6c61057fd"
81
+ "composite": "1d135e6813c9e6f68698812e294083407f5536ab1d083fe316c10df01e3dca29"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.13",
3
+ "version": "0.124.15",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.124.13",
3
+ "version": "0.124.15",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,6 +61,6 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.124.13"
64
+ "@intentius/chant": "^0.124.15"
65
65
  }
66
66
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The GitHub Actions job pieces the three generators share (#3765): the Op
3
+ * pipeline (`./generate-op-pipeline.ts`), the Op waves pipeline
4
+ * (`./generate-op-waves-pipeline.ts`) and the component pipeline
5
+ * (`./generate-pipeline.ts`). The forgejo lexicon builds on the same
6
+ * documents and applies its dialect. The forge-neutral rules (image default,
7
+ * branch defaults, tokens, permissions, setup and environment checks) are
8
+ * core's, in `@intentius/chant/ci-jobs`.
9
+ */
10
+
11
+ import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
12
+ import { GITHUB_JOB_TOKEN, pushBranches } from "@intentius/chant/ci-jobs";
13
+ import type { OpTrigger } from "@intentius/chant/lexicon";
14
+ import { actionRef } from "../action-pins";
15
+
16
+ /** The artifact actions every generated job uses, at the major the generators have always pinned. */
17
+ export const UPLOAD_ARTIFACT = "actions/upload-artifact@v4";
18
+ export const DOWNLOAD_ARTIFACT = "actions/download-artifact@v4";
19
+
20
+ /** The checkout step; `fullHistory` fetches every commit, for a job that reads the base commit or measures a change with git. */
21
+ export function checkoutStep(fullHistory = false): Record<string, unknown> {
22
+ return fullHistory ? { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } } : { uses: actionRef("actions/checkout") };
23
+ }
24
+
25
+ /** One `run:` step per line, so each line is its own step in the log. */
26
+ export function runSteps(lines: readonly string[]): Array<Record<string, unknown>> {
27
+ return lines.map((line) => ({ run: line }));
28
+ }
29
+
30
+ /** A step that downloads `artifact` into `path`. */
31
+ export function downloadArtifactStep(name: string, artifact: string, path: string): Record<string, unknown> {
32
+ return { name, uses: DOWNLOAD_ARTIFACT, with: { name: artifact, path } };
33
+ }
34
+
35
+ /**
36
+ * A step that keeps a record as an artifact whatever the job's outcome, so a
37
+ * job that stops at a gate (exit 3) still hands its record on, and a re-run
38
+ * replaces it. Nothing to keep is not an error.
39
+ */
40
+ export function keepRecordStep(name: string, artifact: string, path: string): Record<string, unknown> {
41
+ return {
42
+ name,
43
+ if: "always()",
44
+ uses: UPLOAD_ARTIFACT,
45
+ with: { name: artifact, path, "if-no-files-found": "ignore", "include-hidden-files": true, overwrite: true },
46
+ };
47
+ }
48
+
49
+ /** A `concurrency:` mapping: one run at a time per group. */
50
+ export function concurrency(group: string, cancelInProgress = false): Record<string, unknown> {
51
+ return { group, "cancel-in-progress": cancelInProgress };
52
+ }
53
+
54
+ /** The job token, as a step's `GITHUB_TOKEN`; `gh` reads `GH_TOKEN` too, for a step that writes. */
55
+ export function jobTokenEnv(gh = false): Record<string, string> {
56
+ return gh ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN, GH_TOKEN: GITHUB_JOB_TOKEN } : { GITHUB_TOKEN: GITHUB_JOB_TOKEN };
57
+ }
58
+
59
+ /**
60
+ * A trigger's `on:` mapping (#2084): `cron` runs on its schedule and by hand,
61
+ * `pull_request` on pull requests into its branches (every branch when it
62
+ * names none; no `workflow_dispatch`, since a PR event needs no manual
63
+ * escape hatch), `push` on pushes to its branches (core's default when it
64
+ * names none).
65
+ */
66
+ export function onTrigger(trigger: OpTrigger): Record<string, unknown> {
67
+ switch (trigger.kind) {
68
+ case "cron":
69
+ return { schedule: [{ cron: trigger.schedule }], workflow_dispatch: {} };
70
+ case "pull_request":
71
+ return { pull_request: trigger.branches && trigger.branches.length > 0 ? { branches: trigger.branches } : {} };
72
+ case "push":
73
+ return { push: { branches: pushBranches(trigger.branches) } };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * A workflow document before emission. Every generator's document is one of
79
+ * these; each leaves out the sections it has no use for.
80
+ */
81
+ export interface GithubWorkflowDoc {
82
+ /** Comment lines above the document, `#` included: what a dialect could not carry across (#2257). */
83
+ header?: string[];
84
+ /** The workflow's `name:`. */
85
+ name?: string;
86
+ /** The `on:` trigger mapping. */
87
+ on: Record<string, unknown>;
88
+ /** The workflow-level `env:` mapping, emitted when it has an entry. */
89
+ env?: Record<string, unknown>;
90
+ /** The workflow's `defaults:`. */
91
+ defaults?: Record<string, unknown>;
92
+ /** The `concurrency:` mapping. */
93
+ concurrency?: Record<string, unknown>;
94
+ /** The workflow-level `permissions:` mapping, emitted when it has an entry. */
95
+ permissions?: Record<string, unknown>;
96
+ /** The `jobs:` mapping. */
97
+ jobsDoc: Record<string, unknown>;
98
+ }
99
+
100
+ /** Emit a workflow document as YAML, its sections in GitHub's customary order. */
101
+ export function emitWorkflowYAML(doc: GithubWorkflowDoc): string {
102
+ const sections: string[] = [];
103
+ if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
104
+ if (doc.name) sections.push("name: " + emitYAML(doc.name, 0));
105
+ sections.push(emitYAMLEntry("on", doc.on));
106
+ if (doc.env && Object.keys(doc.env).length > 0) sections.push(emitYAMLEntry("env", doc.env));
107
+ if (doc.defaults) sections.push(emitYAMLEntry("defaults", doc.defaults));
108
+ if (doc.concurrency) sections.push(emitYAMLEntry("concurrency", doc.concurrency));
109
+ if (doc.permissions && Object.keys(doc.permissions).length > 0) sections.push(emitYAMLEntry("permissions", doc.permissions));
110
+ sections.push(emitYAMLEntry("jobs", doc.jobsDoc));
111
+ return sections.join("\n\n") + "\n";
112
+ }
@@ -779,3 +779,12 @@ describe("generateGithubOpPipeline: per-Op variables on the job (#2290)", () =>
779
779
  ).toBe(before);
780
780
  });
781
781
  });
782
+
783
+ describe("an empty branch list means the default, as on GitLab (#3765)", () => {
784
+ test("push with branches: [] filters on main, and pull_request with branches: [] runs on every pull request", () => {
785
+ const push = parseYAML(generateGithubOpPipeline([{ name: "apply", trigger: { kind: "push", branches: [] } }]).files[0]!.yaml);
786
+ expect(push.on).toEqual({ push: { branches: ["main"] } });
787
+ const pr = parseYAML(generateGithubOpPipeline([{ name: "plan", trigger: { kind: "pull_request", branches: [] } }]).files[0]!.yaml);
788
+ expect(pr.on).toEqual({ pull_request: {} });
789
+ });
790
+ });
@@ -71,23 +71,38 @@
71
71
  */
72
72
 
73
73
  import { readFileSync } from "node:fs";
74
- import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
74
+ import {
75
+ assertEnvironment,
76
+ assertSetupSteps,
77
+ CI_IMAGE,
78
+ ciJobName,
79
+ environmentDoc,
80
+ fillCommand,
81
+ GITHUB_JOB_TOKEN,
82
+ GITHUB_RUNNER,
83
+ mergePermissions,
84
+ nonEmpty,
85
+ permissionsFor,
86
+ setupStepDoc,
87
+ } from "@intentius/chant/ci-jobs";
75
88
  import { memberPathFilter, resolveOpTrigger } from "@intentius/chant/lexicon";
76
89
  import type {
77
90
  ComponentPipelineOptions as GenerateGithubOpOptions,
78
- OpEnvironment,
79
91
  OpFindingMode,
80
92
  OpPipelineJob,
81
93
  OpPipelineResult as GenerateGithubOpResult,
82
- OpSetupStep,
83
94
  OpTrigger,
84
95
  PipelineMember,
85
96
  ScheduledOpSpec,
86
97
  } from "@intentius/chant/lexicon";
87
- import { actionRef } from "../action-pins";
98
+ import { checkoutStep, concurrency, emitWorkflowYAML, jobTokenEnv, onTrigger, runSteps } from "./ci-jobs";
88
99
 
89
100
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
90
101
 
102
+ // The setup, environment and permission checks are shared with the Op waves
103
+ // generator and the forgejo lexicon (#3765); they stay importable from here.
104
+ export { assertEnvironment, assertSetupSteps, mergePermissions };
105
+
91
106
  /**
92
107
  * The structured pipeline document behind one generated file, before YAML
93
108
  * emission — exposed so a GitHub-Actions dialect (the forgejo lexicon, #969)
@@ -148,72 +163,6 @@ export interface GithubOpPipelineFile {
148
163
  doc: GithubOpPipelineDoc;
149
164
  }
150
165
 
151
- /** GitHub Actions job ids must match `[a-zA-Z_][a-zA-Z0-9_-]*`; Op names are already kebab-case in every fixture, but normalize defensively (mirrors `./generate-pipeline.ts`'s `toJobName`). */
152
- function toJobName(opName: string): string {
153
- return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
154
- }
155
-
156
- const DEFAULT_IMAGE = "node:22";
157
-
158
- /**
159
- * Default branch assumed for a `push` trigger with no `branches` override
160
- * (#2084). There is no generator constant for a downstream project's actual
161
- * default branch — this operates on a `ScheduledOpSpec`, not a git checkout
162
- * — so "main" is the documented default; set `trigger.branches` explicitly
163
- * for a project whose default branch is something else (e.g. "master").
164
- */
165
- const DEFAULT_PUSH_BRANCH = "main";
166
-
167
- /** This trigger's `on:` mapping (#2084): cron unchanged, `pull_request`/`push` new. */
168
- function onFor(trigger: OpTrigger): Record<string, unknown> {
169
- switch (trigger.kind) {
170
- case "cron":
171
- return { schedule: [{ cron: trigger.schedule }], workflow_dispatch: {} };
172
- case "pull_request":
173
- // No `workflow_dispatch`: a PR trigger needs no manual-dispatch escape
174
- // hatch (revisit in review if that's wrong).
175
- return { pull_request: trigger.branches ? { branches: trigger.branches } : {} };
176
- case "push":
177
- return { push: { branches: trigger.branches ?? [DEFAULT_PUSH_BRANCH] } };
178
- }
179
- }
180
-
181
- /**
182
- * Least-privilege `permissions:` for a scheduled Op's finding-mode and
183
- * trigger. `report` needs no write access; `issue` needs only `issues:
184
- * write`; `pull-request` (and `merge-request`, generated the same way when a
185
- * GitLab-authored spec is targeted at github) needs `contents: write` to
186
- * push the reconcile branch plus `pull-requests: write` to open the PR. A
187
- * `pull_request` trigger reports its finding as a comment on the triggering
188
- * PR itself (#2084): any mode but `report` posts something to act on a
189
- * finding, so on that trigger every such mode also gets `pull-requests:
190
- * write` for the comment, whether or not its own scope already included it.
191
- * `comment` is the mode that actually spends that grant (#2231), and it
192
- * changes nothing in the repository, so its whole scope is `{ contents: read,
193
- * pull-requests: write }`.
194
- */
195
- function permissionsForMode(mode: OpFindingMode): Record<string, "read" | "write"> {
196
- switch (mode) {
197
- case "issue":
198
- return { contents: "read", issues: "write" };
199
- case "comment":
200
- return { contents: "read", "pull-requests": "write" };
201
- case "pull-request":
202
- case "merge-request":
203
- return { contents: "write", "pull-requests": "write" };
204
- case "report":
205
- return { contents: "read" };
206
- }
207
- }
208
-
209
- function permissionsFor(mode: OpFindingMode, trigger: OpTrigger): Record<string, "read" | "write"> {
210
- const base = permissionsForMode(mode);
211
- if (trigger.kind === "pull_request" && mode !== "report") {
212
- return { ...base, "pull-requests": "write" };
213
- }
214
- return base;
215
- }
216
-
217
166
  // ── The gated apply (#2243) ─────────────────────────────────────────────────
218
167
 
219
168
  /**
@@ -330,7 +279,8 @@ function chantVersion(): string {
330
279
  * `@intentius/chant/pr-forge` clients, so it posts on GitHub, GitHub
331
280
  * Enterprise Server and Forgejo alike. `forgeTargetFromEnv` reads the forge,
332
281
  * the REST base, the repository, the commit and the token off the job's
333
- * environment (`CHANT_FORGEJO_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`),
282
+ * environment (`FORGE_TOKEN_ORDER`: `CHANT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` on
283
+ * GitHub; `CHANT_FORGEJO_TOKEN` first on Forgejo), the order `reconcilePr` reads too (#3807),
334
284
  * and `pullRequestFor` is the commit-to-pull-request lookup each forge spells
335
285
  * its own way (`commits/{sha}/pulls` on GitHub, `commits/{sha}/pull` on
336
286
  * Forgejo). A commit with no pull request, a direct push, gets a tracking
@@ -408,14 +358,14 @@ function gateNoticeJob(applyJobName: string, image: string): Record<string, unkn
408
358
  return {
409
359
  needs: applyJobName,
410
360
  if: `needs.${applyJobName}.outputs.gated == 'true'`,
411
- "runs-on": "ubuntu-latest",
361
+ "runs-on": GITHUB_RUNNER,
412
362
  container: image,
413
363
  permissions: { contents: "read", issues: "write", "pull-requests": "write" },
414
364
  steps: [
415
365
  {
416
366
  name: "Report the pending gate",
417
367
  env: {
418
- GITHUB_TOKEN: "${{ github.token }}",
368
+ GITHUB_TOKEN: GITHUB_JOB_TOKEN,
419
369
  CHANT_OP: output("op"),
420
370
  CHANT_GATE: output("gate"),
421
371
  CHANT_APPROVE: output("approve"),
@@ -442,216 +392,6 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
442
392
  );
443
393
  }
444
394
 
445
- /**
446
- * Every scope `GITHUB_TOKEN` accepts in a workflow's `permissions:` mapping,
447
- * kebab-cased as GitHub spells them. An additive scope outside this set is
448
- * refused by name rather than emitted: GitHub ignores an unknown key, so
449
- * `id_token` or `idToken` would generate a workflow that looks like it grants
450
- * OIDC and hands the run no token at all.
451
- */
452
- const GITHUB_TOKEN_SCOPES = new Set([
453
- "actions",
454
- "attestations",
455
- "checks",
456
- "contents",
457
- "deployments",
458
- "discussions",
459
- "id-token",
460
- "issues",
461
- "models",
462
- "packages",
463
- "pages",
464
- "pull-requests",
465
- "repository-projects",
466
- "security-events",
467
- "statuses",
468
- ]);
469
-
470
- /**
471
- * Refs that name an action repository's own default branch. A generated
472
- * workflow is committed once and then re-run unattended, often over a cloud
473
- * role, so "whatever was pushed to that repo last" is not a version — the
474
- * code that assumes the role can change between the run somebody reviewed and
475
- * the next one. A release channel the action's author cuts deliberately (`v6`,
476
- * `v6.2.4`, `stable`) or a commit sha is a version, and both pass: this repo's
477
- * own workflows pin `actions/checkout@v6` and `dtolnay/rust-toolchain@stable`
478
- * and name no default branch anywhere.
479
- */
480
- const DEFAULT_BRANCH_REFS = new Set(["main", "master", "head", "default"]);
481
-
482
- /** `owner/repo` or `owner/repo/subpath`, then `@ref`. */
483
- const USES_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)((?:\/[A-Za-z0-9_.-]+)*)@([^\s@]+)$/;
484
-
485
- /**
486
- * Validate a spec's `setup` list (#2242). A `run` entry needs a non-empty
487
- * line and nothing else. A `uses` entry has to be a pinned
488
- * `owner/repo[/subpath]@ref`: no bare `owner/repo`, since an unpinned action
489
- * resolves to its default branch, and no ref in {@link DEFAULT_BRANCH_REFS}
490
- * for the same reason spelled out loud. Local (`./path`) and container
491
- * (`docker://`) refs are refused too — they are legal GitHub Actions, but the
492
- * generator emits a workflow into a repository it has never seen, so it
493
- * cannot know a local path resolves there.
494
- */
495
- export function assertSetupSteps(name: string, setup: OpSetupStep[]): void {
496
- setup.forEach((step, index) => {
497
- const where = `Scheduled Op "${name}" setup step ${index + 1}`;
498
- if ("uses" in step) {
499
- const ref = step.uses.trim();
500
- const match = USES_PATTERN.exec(ref);
501
- if (!match) {
502
- throw new Error(
503
- `${where} has \`uses: "${step.uses}"\`, which is not a pinned action reference. ` +
504
- `Write it as owner/repo@ref (optionally owner/repo/subpath@ref), e.g. ` +
505
- `"aws-actions/configure-aws-credentials@v6". A local "./path" or "docker://" ref is not ` +
506
- `accepted here: this generator emits a workflow into a repository it cannot inspect, so it ` +
507
- `has no way to tell whether such a ref resolves there.`,
508
- );
509
- }
510
- const gitRef = match[4];
511
- if (DEFAULT_BRANCH_REFS.has(gitRef.toLowerCase())) {
512
- throw new Error(
513
- `${where} pins \`uses: "${step.uses}"\` to "${gitRef}", the action repository's own default ` +
514
- `branch, which names whatever was pushed there last rather than a version. A generated ` +
515
- `workflow is committed once and re-run unattended, often over a cloud role, so pin a release ` +
516
- `tag or a commit sha instead (e.g. "${match[1]}/${match[2]}@v1" or "@<40-char sha>").`,
517
- );
518
- }
519
- return;
520
- }
521
- if (step.run.trim() === "") {
522
- throw new Error(`${where} has an empty \`run\` line. Give it a command, or drop the entry.`);
523
- }
524
- });
525
- }
526
-
527
- /**
528
- * Validate a spec's `environment` (#2257). GitHub creates an environment it
529
- * has never seen on first use rather than failing the run, and an environment
530
- * created that way carries no protection rules at all — so a job can name one
531
- * and read as gated while being gated by nothing. Neither this generator nor
532
- * GitHub can tell those apart at build time (the environment and its
533
- * reviewers are repository configuration, not workflow content), which is why
534
- * what is refused here is only the shape that could never bind: a name that
535
- * is blank, and a `url` that is neither absolute nor an expression the forge
536
- * resolves. The rest is the README's job to say out loud.
537
- */
538
- export function assertEnvironment(name: string, environment: OpEnvironment): void {
539
- const where = `Scheduled Op "${name}" environment`;
540
- if (environment.name.trim() === "") {
541
- throw new Error(
542
- `${where} has an empty \`name\`. An environment is named repository configuration — the ` +
543
- `protection rules and reviewers live on the environment, not in this workflow — so there is ` +
544
- `nothing for a blank name to resolve to. Give it the environment's name, or drop the option.`,
545
- );
546
- }
547
- if (environment.url === undefined) return;
548
- const url = environment.url.trim();
549
- if (url === "") {
550
- throw new Error(
551
- `${where} "${environment.name}" has an empty \`url\`. Omit the field rather than setting it to "".`,
552
- );
553
- }
554
- if (!/^https?:\/\//.test(url) && !url.includes("${{")) {
555
- throw new Error(
556
- `${where} "${environment.name}" has \`url: "${environment.url}"\`, which is neither an absolute ` +
557
- `http(s) URL nor a \${{ }} expression. GitHub renders this value as the deployment's own link, ` +
558
- `so a relative path becomes a dead link on the environment page rather than an error anywhere. ` +
559
- `Write the full URL, or an expression the run resolves to one.`,
560
- );
561
- }
562
- }
563
-
564
- /**
565
- * Merge a spec's additive `permissions` over the finding-mode's own set
566
- * (#2242), refusing by name anything that is not strictly additive:
567
- *
568
- * - a blanket `write-all`/`read-all`, in either the key or the value
569
- * position, which is the exact thing {@link permissionsForMode} exists to
570
- * avoid;
571
- * - a scope GitHub does not define ({@link GITHUB_TOKEN_SCOPES}), because
572
- * GitHub ignores the key and the run silently gets nothing;
573
- * - a scope the mode already grants, at any value — additive means additive,
574
- * so this can neither downgrade `contents: write` to read nor restate it.
575
- * A mode whose set is wrong is fixed by changing the mode, where the
576
- * scope and the behavior that spends it stay together;
577
- * - `pull-requests: write` on a trigger with no pull request. Pull-request
578
- * access is what the finding-modes own: `pull-request` grants it together
579
- * with the `contents: write` needed to push the branch first, and
580
- * `comment` grants it on the one trigger that carries a pull request to
581
- * comment on. Adding it beside a mode that posts nothing, on a cron or
582
- * push run, grants write access no step in the generated job can spend.
583
- */
584
- export function mergePermissions(
585
- name: string,
586
- base: Record<string, "read" | "write">,
587
- additive: Record<string, "read" | "write">,
588
- trigger: OpTrigger,
589
- ): Record<string, "read" | "write"> {
590
- const merged: Record<string, "read" | "write"> = { ...base };
591
- for (const [rawScope, value] of Object.entries(additive)) {
592
- const scope = rawScope.trim();
593
- const where = `Scheduled Op "${name}" adds permission "${scope}: ${value}"`;
594
- if (scope === "write-all" || scope === "read-all" || String(value).endsWith("-all")) {
595
- throw new Error(
596
- `${where}, a blanket grant. \`permissions\` on a scheduled Op is additive over the ` +
597
- `least-privilege set its finding-mode needs, one named scope at a time. Name the scopes the ` +
598
- `job actually spends (e.g. { "id-token": "write" }).`,
599
- );
600
- }
601
- if (!GITHUB_TOKEN_SCOPES.has(scope)) {
602
- throw new Error(
603
- `${where}, which is not a GITHUB_TOKEN permission scope. GitHub ignores an unrecognized key, so ` +
604
- `this would emit a workflow that reads as granted and hands the run nothing. Known scopes: ` +
605
- `${[...GITHUB_TOKEN_SCOPES].sort().join(", ")}.`,
606
- );
607
- }
608
- if (scope in base) {
609
- throw new Error(
610
- `${where}, but its finding-mode already grants "${scope}: ${base[scope]}". These permissions are ` +
611
- `additive only — they never replace, widen or downgrade a scope the mode computed. Change the ` +
612
- `Op's findingMode if that set is wrong, and add only scopes no mode grants (e.g. "id-token").`,
613
- );
614
- }
615
- if (scope === "pull-requests" && trigger.kind !== "pull_request") {
616
- throw new Error(
617
- `${where}, but this Op's trigger is "${trigger.kind}", which carries no pull request. Pull-request ` +
618
- `write access belongs to a finding-mode: "pull-request" grants it with the contents: write its ` +
619
- `branch push needs, and "comment" grants it on the pull_request trigger. Set findingMode instead ` +
620
- `of adding the scope here.`,
621
- );
622
- }
623
- merged[scope] = value;
624
- }
625
- return merged;
626
- }
627
-
628
- /**
629
- * Emit a spec's environment as the job's `environment:` mapping. Always the
630
- * mapping form, never the `environment: name` string shorthand, so adding a
631
- * `url` later is a new key rather than a reshaped value.
632
- */
633
- function environmentDoc(environment: OpEnvironment): Record<string, unknown> {
634
- return {
635
- name: environment.name,
636
- ...(environment.url === undefined ? {} : { url: environment.url }),
637
- };
638
- }
639
-
640
- /** Emit one setup entry as a GitHub Actions step. */
641
- function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
642
- if ("uses" in step) {
643
- return {
644
- uses: step.uses,
645
- ...(step.with && Object.keys(step.with).length > 0 ? { with: step.with } : {}),
646
- ...(step.env && Object.keys(step.env).length > 0 ? { env: step.env } : {}),
647
- };
648
- }
649
- return {
650
- run: step.run,
651
- ...(step.env && Object.keys(step.env).length > 0 ? { env: step.env } : {}),
652
- };
653
- }
654
-
655
395
  /**
656
396
  * Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
657
397
  * steps, least-privilege `permissions:` for its finding-mode plus whatever
@@ -668,7 +408,7 @@ export function buildGithubOpPipelineDocs(
668
408
  ops: ScheduledOpSpec[],
669
409
  options: GenerateGithubOpOptions = {},
670
410
  ): { files: GithubOpPipelineFile[]; jobs: OpPipelineJob[] } {
671
- const image = options.image ?? DEFAULT_IMAGE;
411
+ const image = options.image ?? CI_IMAGE;
672
412
  const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
673
413
  const beforeScript = options.beforeScript ?? [];
674
414
  const extraScript = options.extraScript ?? [];
@@ -680,15 +420,14 @@ export function buildGithubOpPipelineDocs(
680
420
  const findingMode = spec.findingMode ?? "report";
681
421
  const trigger = resolveOpTrigger(spec);
682
422
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
683
- const jobName = toJobName(spec.name);
423
+ const jobName = ciJobName(spec.name);
684
424
  jobs.push({ jobName, op: spec.name, trigger, findingMode });
685
425
 
686
- const runParts = runCommand.map((part) => part.replace("{name}", spec.name));
426
+ const runParts = fillCommand(runCommand, spec.name);
687
427
 
688
428
  // A live-resolution read (rate limits) always benefits from a token;
689
429
  // creating an issue/PR additionally needs `gh` CLI's own token variable.
690
- const stepEnv: Record<string, string> = { GITHUB_TOKEN: "${{ github.token }}" };
691
- if (findingMode !== "report") stepEnv.GH_TOKEN = "${{ github.token }}";
430
+ const stepEnv = jobTokenEnv(findingMode !== "report");
692
431
 
693
432
  const setup = spec.setup ?? [];
694
433
  assertSetupSteps(spec.name, setup);
@@ -703,23 +442,21 @@ export function buildGithubOpPipelineDocs(
703
442
  ? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
704
443
  : runParts.join(" ");
705
444
 
706
- const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
707
- for (const step of setup) steps.push(setupStepDoc(step));
708
- for (const line of beforeScript) steps.push({ run: line });
445
+ const steps: Array<Record<string, unknown>> = [checkoutStep(), ...setup.map(setupStepDoc), ...runSteps(beforeScript)];
709
446
  steps.push(
710
447
  gated
711
448
  ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
712
449
  : { run: invocation, env: stepEnv },
713
450
  );
714
- for (const line of extraScript) steps.push({ run: line });
451
+ steps.push(...runSteps(extraScript));
715
452
 
716
453
  const doc: GithubOpPipelineDoc = {
717
454
  name: spec.name,
718
- on: onFor(trigger),
719
- ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
455
+ on: onTrigger(trigger),
456
+ ...nonEmpty("env", options.variables),
720
457
  // One run at a time per Op — a slow audit must not overlap its own next
721
458
  // scheduled trigger.
722
- concurrency: { group: jobName, "cancel-in-progress": false },
459
+ concurrency: concurrency(jobName),
723
460
  permissions: mergePermissions(
724
461
  spec.name,
725
462
  permissionsFor(findingMode, trigger),
@@ -728,13 +465,13 @@ export function buildGithubOpPipelineDocs(
728
465
  ),
729
466
  jobsDoc: {
730
467
  [jobName]: {
731
- "runs-on": "ubuntu-latest",
468
+ "runs-on": GITHUB_RUNNER,
732
469
  container: image,
733
470
  // Per-Op credentials (#2290): job-level `env:`, one level more
734
471
  // specific than `options.variables`'s workflow-level `env:` above,
735
472
  // and never on the notice job beside it — same reason `environment:`
736
473
  // isn't either, it's this Op's own job alone.
737
- ...(spec.variables && Object.keys(spec.variables).length > 0 ? { env: spec.variables } : {}),
474
+ ...nonEmpty("env", spec.variables),
738
475
  // On this Op's own job and never on the notice job beside it: the
739
476
  // notice exists to say a chant gate is pending, and putting it
740
477
  // behind the same reviewer would hold the message back until
@@ -810,20 +547,11 @@ function scopeOpFileToMember(file: GithubOpPipelineFile, member: PipelineMember)
810
547
  * transforms the doc first (and drops `permissions`, which it ignores).
811
548
  */
812
549
  export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
813
- const sections: string[] = [];
814
- // A dialect's note about what it could not carry (#2257), when there is
815
- // one. Absent on github, so an unannotated document is emitted exactly as
816
- // it was before the field existed.
817
- if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
818
- if (doc.name) sections.push("name: " + emitYAML(doc.name, 0));
819
- sections.push(emitYAMLEntry("on", doc.on));
820
- if (doc.env && Object.keys(doc.env).length > 0) sections.push(emitYAMLEntry("env", doc.env));
821
- sections.push(emitYAMLEntry("concurrency", doc.concurrency));
822
- if (Object.keys(doc.permissions).length > 0) sections.push(emitYAMLEntry("permissions", doc.permissions));
823
550
  // The gated-apply notice job rides in `jobs:` beside the Op's own job; it
824
551
  // is carried separately on the doc so a dialect transforms it on its own.
825
- sections.push(emitYAMLEntry("jobs", { ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }));
826
- return sections.join("\n\n") + "\n";
552
+ // A dialect's `header` note (#2257) is absent on github.
553
+ const { gatedNoticeDoc, ...workflow } = doc;
554
+ return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}) } });
827
555
  }
828
556
 
829
557
  /**
@@ -31,27 +31,30 @@
31
31
 
32
32
  import { notifyVariables, opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
33
33
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
34
- import { actionRef } from "../action-pins";
35
34
  import {
36
35
  assertEnvironment,
37
36
  assertSetupSteps,
38
- emitOpPipelineYAML,
39
- type GithubOpPipelineDoc,
40
- } from "./generate-op-pipeline";
37
+ CI_IMAGE,
38
+ dirnameOf,
39
+ environmentDoc,
40
+ GITHUB_JOB_TOKEN,
41
+ GITHUB_RUNNER,
42
+ githubSecretEnv,
43
+ nonEmpty,
44
+ pushBranches,
45
+ setupStepDoc,
46
+ } from "@intentius/chant/ci-jobs";
47
+ import { checkoutStep, concurrency, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
48
+ import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
41
49
 
42
50
  /** The default image: it carries git, which the runner needs to read the base commit. */
43
- export const OP_WAVES_IMAGE = "node:22";
51
+ export const OP_WAVES_IMAGE = CI_IMAGE;
44
52
 
45
53
  /** The artifact a wide wave's deciding job hands its share jobs. */
46
54
  export function opWaveRecordArtifact(spec: Pick<OpWavesSpec, "name">, wave: number): string {
47
55
  return `${spec.name}-wave-${wave}-decision`;
48
56
  }
49
57
 
50
- function dirOf(path: string): string {
51
- const i = path.lastIndexOf("/");
52
- return i < 0 ? "." : path.slice(0, i);
53
- }
54
-
55
58
  /** The workflow document, before emission, and the jobs it holds. */
56
59
  export function buildGithubOpWavesDoc(
57
60
  spec: OpWavesSpec,
@@ -68,51 +71,33 @@ export function buildGithubOpWavesDoc(
68
71
  const jobsDoc: Record<string, unknown> = {};
69
72
  for (const job of jobs) {
70
73
  const wave = spec.waves[job.wave - 1]!;
71
- const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } }];
74
+ const steps: Array<Record<string, unknown>> = [checkoutStep(true)];
72
75
  if (job.kind === "share") {
73
- steps.push({
74
- name: `Download wave ${job.wave}'s decision`,
75
- uses: "actions/download-artifact@v4",
76
- with: { name: opWaveRecordArtifact(spec, job.wave), path: dirOf(job.record!) },
77
- });
76
+ steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
78
77
  }
79
- for (const step of wave.setup ?? []) steps.push({ ...step });
80
- for (const line of options.beforeScript ?? []) steps.push({ run: line });
78
+ steps.push(...(wave.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
81
79
  steps.push({ name: describeJob(job.kind, job.wave, wave.name, job.share), run: job.command.join(" ") });
82
- for (const line of options.extraScript ?? []) steps.push({ run: line });
80
+ steps.push(...runSteps(options.extraScript ?? []));
83
81
  if (job.kind === "decide") {
84
- steps.push({
85
- name: `Keep wave ${job.wave}'s decision`,
86
- if: "always()",
87
- uses: "actions/upload-artifact@v4",
88
- with: {
89
- name: opWaveRecordArtifact(spec, job.wave),
90
- path: job.record!,
91
- "if-no-files-found": "ignore",
92
- "include-hidden-files": true,
93
- overwrite: true,
94
- },
95
- });
82
+ steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
96
83
  }
97
84
  jobsDoc[job.jobName] = {
98
- "runs-on": "ubuntu-latest",
85
+ "runs-on": GITHUB_RUNNER,
99
86
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
100
87
  container: image,
101
- ...(wave.environment
102
- ? { environment: { name: wave.environment.name, ...(wave.environment.url ? { url: wave.environment.url } : {}) } }
103
- : {}),
104
- ...(wave.variables && Object.keys(wave.variables).length > 0 ? { env: wave.variables } : {}),
88
+ ...(wave.environment ? { environment: environmentDoc(wave.environment) } : {}),
89
+ ...nonEmpty("env", wave.variables),
105
90
  steps,
106
91
  };
107
92
  }
108
93
 
109
94
  // #3761: the notify variables come from the secrets of the same name.
110
- const env = { ...Object.fromEntries(notifyVariables(spec.notify).map((n) => [n, `\${{ secrets.${n} }}`])), ...(options.variables ?? {}) };
95
+ const env = { ...githubSecretEnv(notifyVariables(spec.notify)), ...(options.variables ?? {}) };
111
96
  const doc: GithubOpPipelineDoc = {
112
97
  name: spec.name,
113
- on: { push: { branches: spec.branches?.length ? spec.branches : ["main"] }, workflow_dispatch: {} },
114
- ...(Object.keys(env).length > 0 ? { env } : {}),
115
- concurrency: { group: spec.name, "cancel-in-progress": false },
98
+ on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
99
+ ...nonEmpty("env", env),
100
+ concurrency: concurrency(spec.name),
116
101
  permissions: { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) },
117
102
  jobsDoc,
118
103
  };
@@ -126,37 +111,34 @@ export function buildGithubOpWavesDoc(
126
111
 
127
112
  /** The scheduled resume workflow (#3683). */
128
113
  function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
129
- const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
130
- for (const line of options.beforeScript ?? []) steps.push({ run: line });
114
+ const steps: Array<Record<string, unknown>> = [checkoutStep(), ...runSteps(options.beforeScript ?? [])];
131
115
  steps.push({
132
116
  name: `Resume the waves of ${spec.name} whose approval arrived`,
133
117
  run: opWavesResumeCommand(spec).join(" "),
134
- env: { GITHUB_TOKEN: "${{ github.token }}" },
118
+ env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN },
135
119
  });
136
120
  return {
137
121
  name: `${spec.name}-resume`,
138
122
  on: { schedule: [{ cron: spec.resume!.schedule }], workflow_dispatch: {} },
139
- concurrency: { group: `${spec.name}-resume`, "cancel-in-progress": true },
123
+ concurrency: concurrency(`${spec.name}-resume`, true),
140
124
  // contents: read fetches chant/lifecycle; actions: write re-runs a waiting run.
141
125
  permissions: { contents: "read", actions: "write" },
142
- jobsDoc: { resume: { "runs-on": "ubuntu-latest", container: options.image ?? OP_WAVES_IMAGE, steps } },
126
+ jobsDoc: { resume: { "runs-on": GITHUB_RUNNER, container: options.image ?? OP_WAVES_IMAGE, steps } },
143
127
  };
144
128
  }
145
129
 
146
130
  /** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
147
131
  function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
148
- const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } }];
149
- for (const step of spec.waves[0]?.setup ?? []) steps.push({ ...step });
150
- for (const line of options.beforeScript ?? []) steps.push({ run: line });
132
+ const steps: Array<Record<string, unknown>> = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
151
133
  steps.push({ name: "Plan every wave at the head and record its digests", run: ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ") });
152
134
  return {
153
135
  name: `${spec.name}-plans`,
154
- on: { pull_request: { branches: spec.branches?.length ? spec.branches : ["main"] } },
155
- ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
156
- concurrency: { group: spec.name + "-plans-${{ github.event.number }}", "cancel-in-progress": true },
136
+ on: { pull_request: { branches: pushBranches(spec.branches) } },
137
+ ...nonEmpty("env", options.variables),
138
+ concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
157
139
  // contents: write pushes the record to chant/lifecycle.
158
140
  permissions: { contents: "write" },
159
- jobsDoc: { "record-plans": { "runs-on": "ubuntu-latest", container: options.image ?? OP_WAVES_IMAGE, steps } },
141
+ jobsDoc: { "record-plans": { "runs-on": GITHUB_RUNNER, container: options.image ?? OP_WAVES_IMAGE, steps } },
160
142
  };
161
143
  }
162
144
 
@@ -56,11 +56,11 @@
56
56
  * touching the component declarations.
57
57
  */
58
58
 
59
- import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
60
59
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
61
60
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
62
61
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
63
- import { PR_APPLY_RECORD, PR_LOOP_IMAGE, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
62
+ import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
63
+ import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
64
64
  import { memberRepoPath } from "@intentius/chant/lexicon";
65
65
  import type {
66
66
  ComponentPipelineJob as GeneratedJob,
@@ -68,7 +68,7 @@ import type {
68
68
  ComponentPipelineResult as GenerateGithubResult,
69
69
  PipelineMember,
70
70
  } from "@intentius/chant/lexicon";
71
- import { actionRef } from "../action-pins";
71
+ import { checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs";
72
72
 
73
73
  export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
74
74
 
@@ -134,32 +134,14 @@ function gatedWaveGithubDoc(
134
134
  const jobs: GeneratedJob[] = [];
135
135
  for (const job of waveJobs) {
136
136
  jobs.push({ jobName: job.jobName, component: `wave ${job.wave}`, stage: job.jobName, needs: job.needs });
137
- const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } }];
137
+ const steps: Array<Record<string, unknown>> = [checkoutStep(true)];
138
138
  if (job.wave > 1) {
139
- steps.push({
140
- name: `Download the record wave ${job.wave - 1} left`,
141
- uses: "actions/download-artifact@v4",
142
- with: { name: waveRecordArtifact(job.wave - 1), path: dirnameOf(GATED_WAVE_RECORD) },
143
- });
139
+ steps.push(downloadArtifactStep(`Download the record wave ${job.wave - 1} left`, waveRecordArtifact(job.wave - 1), dirnameOf(GATED_WAVE_RECORD)));
144
140
  }
145
- for (const line of prLoopSetup(image)) steps.push({ run: line });
146
- for (const line of beforeScript) steps.push({ run: line });
147
- steps.push({ run: job.command.join(" ") });
148
- for (const line of extraScript) steps.push({ run: line });
149
- steps.push({
150
- name: `Upload the record for wave ${job.wave}`,
151
- if: "always()",
152
- uses: "actions/upload-artifact@v4",
153
- with: {
154
- name: waveRecordArtifact(job.wave),
155
- path: GATED_WAVE_RECORD,
156
- "if-no-files-found": "ignore",
157
- "include-hidden-files": true,
158
- overwrite: true,
159
- },
160
- });
141
+ steps.push(...runSteps(prLoopSetup(image)), ...runSteps(beforeScript), { run: job.command.join(" ") }, ...runSteps(extraScript));
142
+ steps.push(keepRecordStep(`Upload the record for wave ${job.wave}`, waveRecordArtifact(job.wave), GATED_WAVE_RECORD));
161
143
  jobsDoc[job.jobName] = {
162
- "runs-on": "ubuntu-latest",
144
+ "runs-on": GITHUB_RUNNER,
163
145
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
164
146
  container: image,
165
147
  steps,
@@ -225,33 +207,33 @@ function prLoopGithubDoc(
225
207
  with: { path: recordPath, key: recordKey },
226
208
  };
227
209
  const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
228
- { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } },
229
- ...prLoopSetup(image).map((line) => ({ run: line })),
230
- ...beforeScript.map((line) => ({ run: line })),
210
+ checkoutStep(true),
211
+ ...runSteps(prLoopSetup(image)),
212
+ ...runSteps(beforeScript),
231
213
  ...(job.jobName === "apply" ? [restoreRecord] : []),
232
214
  { name, env: job.env, run: job.command },
233
- ...extraScript.map((line) => ({ run: line })),
215
+ ...runSteps(extraScript),
234
216
  ...(job.jobName === "apply" ? [saveRecord] : []),
235
217
  {
236
218
  name: `Keep the ${job.jobName} report`,
237
219
  if: "always()",
238
- uses: "actions/upload-artifact@v4",
220
+ uses: UPLOAD_ARTIFACT,
239
221
  with: { name: `chant-pr-${job.jobName}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true },
240
222
  },
241
223
  ];
242
224
  const jobsDoc: Record<string, unknown> = {
243
225
  plan: {
244
226
  if: "github.event_name == 'pull_request'",
245
- "runs-on": "ubuntu-latest",
227
+ "runs-on": GITHUB_RUNNER,
246
228
  container: image,
247
229
  permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
248
230
  steps: steps(plan, "Plan the members this pull request reaches"),
249
231
  },
250
232
  apply: {
251
233
  if: "github.event_name == 'push'",
252
- "runs-on": "ubuntu-latest",
234
+ "runs-on": GITHUB_RUNNER,
253
235
  container: image,
254
- concurrency: { group: prApplyGroup(env, member?.name), "cancel-in-progress": false },
236
+ concurrency: concurrency(prApplyGroup(env, member?.name)),
255
237
  permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
256
238
  steps: steps(apply, "Apply the plan a reviewer approved"),
257
239
  },
@@ -271,19 +253,6 @@ function prLoopGithubDoc(
271
253
  };
272
254
  }
273
255
 
274
- /** The directory part of a relative path, `.` when there is none. */
275
- function dirnameOf(path: string): string {
276
- const i = path.lastIndexOf("/");
277
- return i <= 0 ? "." : path.slice(0, i);
278
- }
279
-
280
- /** GitHub Actions job ids must match `[a-zA-Z_][a-zA-Z0-9_-]*`; component names are already kebab-case in every fixture, but normalize defensively (mirrors gitlab's `toJobName`). */
281
- function toJobName(componentName: string): string {
282
- return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
283
- }
284
-
285
- const DEFAULT_IMAGE = "node:22";
286
-
287
256
  /** The file a producer dumps its resolved outputs to, and a dependent seeds from — one per component, matching gitlab's naming so both generators produce interchangeable artifacts. */
288
257
  function outputsFile(name: string): string {
289
258
  return `${name}.outputs.json`;
@@ -336,7 +305,7 @@ export function buildGithubPipelineDoc(
336
305
  options: GenerateGithubOptions = {},
337
306
  ): GithubPipelineDoc {
338
307
  const env = options.env ?? "production";
339
- const image = options.image ?? (options.prLoop || options.gatedWaves ? PR_LOOP_IMAGE : DEFAULT_IMAGE);
308
+ const image = options.image ?? CI_IMAGE;
340
309
  const runCommand = options.runCommand ?? ["chant", "run", "--components", "{name}", "--env", env];
341
310
  const beforeScript = options.beforeScript ?? [];
342
311
  const extraScript = options.extraScript ?? [];
@@ -390,7 +359,7 @@ export function buildGithubPipelineDoc(
390
359
  const jobs: GeneratedJob[] = [];
391
360
  const jobNameByComponent = new Map<string, string>();
392
361
  for (const wave of waves) {
393
- for (const name of wave) jobNameByComponent.set(name, toJobName(name));
362
+ for (const name of wave) jobNameByComponent.set(name, ciJobName(name));
394
363
  }
395
364
 
396
365
  const jobsDoc: Record<string, unknown> = {};
@@ -407,7 +376,7 @@ export function buildGithubPipelineDoc(
407
376
  // Build the run invocation, then append output-threading flags: seed from
408
377
  // each dependency's dumped outputs (downloaded as an artifact below), and
409
378
  // dump this component's own outputs if a dependent will need them.
410
- const runParts = runCommand.map((part) => part.replace("{name}", name));
379
+ const runParts = fillCommand(runCommand, name);
411
380
  for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
412
381
  if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
413
382
  if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
@@ -415,19 +384,9 @@ export function buildGithubPipelineDoc(
415
384
  // One step per script line — mirrors gitlab's `script:` array of
416
385
  // discrete shell lines, rather than a single multi-line `run:` block, so
417
386
  // each line is independently inspectable (and machine-parseable).
418
- const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
419
-
420
- for (const dep of component.dependsOn ?? []) {
421
- steps.push({
422
- name: `Download ${dep} outputs`,
423
- uses: "actions/download-artifact@v4",
424
- with: { name: artifactName(dep), path: "." },
425
- });
426
- }
427
-
428
- for (const line of beforeScript) steps.push({ run: line });
429
- steps.push({ run: runParts.join(" ") });
430
- for (const line of extraScript) steps.push({ run: line });
387
+ const steps: Array<Record<string, unknown>> = [checkoutStep()];
388
+ for (const dep of component.dependsOn ?? []) steps.push(downloadArtifactStep(`Download ${dep} outputs`, artifactName(dep), "."));
389
+ steps.push(...runSteps(beforeScript), { run: runParts.join(" ") }, ...runSteps(extraScript));
431
390
 
432
391
  // The file holds `<component>=<digest>`; the job output holds the digest.
433
392
  if (pinned.has(name)) {
@@ -441,7 +400,7 @@ export function buildGithubPipelineDoc(
441
400
  if (dependedUpon.has(name)) {
442
401
  steps.push({
443
402
  name: `Upload ${name} outputs`,
444
- uses: "actions/upload-artifact@v4",
403
+ uses: UPLOAD_ARTIFACT,
445
404
  with: { name: artifactName(name), path: outputsFile(name) },
446
405
  });
447
406
  }
@@ -450,7 +409,7 @@ export function buildGithubPipelineDoc(
450
409
  if (archive) {
451
410
  steps.push({
452
411
  name: `Upload ${name} build archive`,
453
- uses: "actions/upload-artifact@v4",
412
+ uses: UPLOAD_ARTIFACT,
454
413
  with: {
455
414
  name: archiveArtifactName(name),
456
415
  path: archive.join("\n"),
@@ -461,7 +420,7 @@ export function buildGithubPipelineDoc(
461
420
  }
462
421
 
463
422
  const jobProps: Record<string, unknown> = {
464
- "runs-on": "ubuntu-latest",
423
+ "runs-on": GITHUB_RUNNER,
465
424
  ...(needs.length > 0 ? { needs } : {}),
466
425
  container: image,
467
426
  ...(pinned.has(name) ? { outputs: { digest: "${{ steps.digest.outputs.digest }}" } } : {}),
@@ -472,7 +431,7 @@ export function buildGithubPipelineDoc(
472
431
  });
473
432
 
474
433
  if (promoteTo !== undefined) {
475
- const promoteJob = `promote-${toJobName(promoteTo)}`;
434
+ const promoteJob = `promote-${ciJobName(promoteTo)}`;
476
435
  if (promoteJob in jobsDoc) {
477
436
  throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
478
437
  }
@@ -480,19 +439,13 @@ export function buildGithubPipelineDoc(
480
439
  for (const name of [...pinned].sort()) {
481
440
  command.push("--digest", `"${name}=\${{ needs.${jobNameByComponent.get(name)!}.outputs.digest }}"`);
482
441
  }
483
- const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
442
+ const steps: Array<Record<string, unknown>> = [checkoutStep()];
484
443
  for (const [name, paths] of archives) {
485
- steps.push({
486
- name: `Download ${name} build archive`,
487
- uses: "actions/download-artifact@v4",
488
- with: { name: archiveArtifactName(name), path: artifactRoot(paths) },
489
- });
444
+ steps.push(downloadArtifactStep(`Download ${name} build archive`, archiveArtifactName(name), artifactRoot(paths)));
490
445
  }
491
- for (const line of beforeScript) steps.push({ run: line });
492
- steps.push({ run: command.join(" ") });
493
- for (const line of extraScript) steps.push({ run: line });
446
+ steps.push(...runSteps(beforeScript), { run: command.join(" ") }, ...runSteps(extraScript));
494
447
  jobsDoc[promoteJob] = {
495
- "runs-on": "ubuntu-latest",
448
+ "runs-on": GITHUB_RUNNER,
496
449
  needs: [...jobNameByComponent.values()].sort(),
497
450
  container: image,
498
451
  steps,
@@ -558,15 +511,7 @@ function memberArtifactStep(step: Record<string, unknown>, member: PipelineMembe
558
511
  * Shared with the forgejo dialect (#969), which transforms the doc first.
559
512
  */
560
513
  export function emitPipelineYAML(doc: GithubPipelineDoc): string {
561
- const sections: string[] = [];
562
- sections.push("name: " + emitYAML(doc.name, 0));
563
- sections.push(emitYAMLEntry("on", doc.on));
564
- if (doc.env && Object.keys(doc.env).length > 0) {
565
- sections.push(emitYAMLEntry("env", doc.env));
566
- }
567
- if (doc.defaults) sections.push(emitYAMLEntry("defaults", doc.defaults));
568
- sections.push(emitYAMLEntry("jobs", doc.jobsDoc));
569
- return sections.join("\n\n") + "\n";
514
+ return emitWorkflowYAML({ name: doc.name, on: doc.on, env: doc.env, defaults: doc.defaults, jobsDoc: doc.jobsDoc });
570
515
  }
571
516
 
572
517
  /**