@intentius/chant-lexicon-github 0.124.14 → 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.
- package/dist/components/ci-jobs.d.ts +62 -0
- package/dist/components/ci-jobs.d.ts.map +1 -0
- package/dist/components/generate-op-pipeline.d.ts +3 -45
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/ci-jobs.ts +112 -0
- package/src/components/generate-op-pipeline.test.ts +9 -0
- package/src/components/generate-op-pipeline.ts +37 -309
- package/src/components/generate-op-waves-pipeline.ts +34 -52
- package/src/components/generate-pipeline.ts +31 -86
|
@@ -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
|
|
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;
|
|
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;
|
|
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;
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
81
|
+
"composite": "1d135e6813c9e6f68698812e294083407f5536ab1d083fe316c10df01e3dca29"
|
|
82
82
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "0.124.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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 (`
|
|
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":
|
|
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:
|
|
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 ??
|
|
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 =
|
|
423
|
+
const jobName = ciJobName(spec.name);
|
|
684
424
|
jobs.push({ jobName, op: spec.name, trigger, findingMode });
|
|
685
425
|
|
|
686
|
-
const runParts = runCommand
|
|
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
|
|
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>> = [
|
|
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
|
-
|
|
451
|
+
steps.push(...runSteps(extraScript));
|
|
715
452
|
|
|
716
453
|
const doc: GithubOpPipelineDoc = {
|
|
717
454
|
name: spec.name,
|
|
718
|
-
on:
|
|
719
|
-
...(
|
|
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:
|
|
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":
|
|
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
|
-
...(
|
|
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
|
-
|
|
826
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
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>> = [
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
85
|
+
"runs-on": GITHUB_RUNNER,
|
|
99
86
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
100
87
|
container: image,
|
|
101
|
-
...(wave.environment
|
|
102
|
-
|
|
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 = { ...
|
|
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
|
|
114
|
-
...(
|
|
115
|
-
concurrency:
|
|
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>> = [
|
|
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:
|
|
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:
|
|
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":
|
|
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>> = [
|
|
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
|
|
155
|
-
...(
|
|
156
|
-
concurrency:
|
|
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":
|
|
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 {
|
|
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 {
|
|
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>> = [
|
|
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
|
-
|
|
146
|
-
|
|
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":
|
|
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
|
-
|
|
229
|
-
...prLoopSetup(image)
|
|
230
|
-
...beforeScript
|
|
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
|
|
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:
|
|
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":
|
|
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":
|
|
234
|
+
"runs-on": GITHUB_RUNNER,
|
|
253
235
|
container: image,
|
|
254
|
-
concurrency:
|
|
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 ??
|
|
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,
|
|
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
|
|
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>> = [
|
|
419
|
-
|
|
420
|
-
|
|
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:
|
|
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:
|
|
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":
|
|
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-${
|
|
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>> = [
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
/**
|