@intentius/chant-lexicon-github 0.58.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/generate-op-pipeline.d.ts +74 -10
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/composites/pr-plan-report.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/generate-op-pipeline.test.ts +259 -1
- package/src/components/generate-op-pipeline.ts +402 -12
- package/src/composites/pr-plan-report.test.ts +26 -0
- package/src/composites/pr-plan-report.ts +10 -5
|
@@ -21,16 +21,33 @@
|
|
|
21
21
|
* branch);
|
|
22
22
|
* - declares only the `permissions:` its `findingMode` (and, for a
|
|
23
23
|
* `pull_request` trigger, whether that mode posts a comment) needs —
|
|
24
|
-
* `report` stays read-only, `issue`/`pull-request` add the write
|
|
25
|
-
* the Op's own activity uses (`gh issue create` /
|
|
26
|
-
* `@intentius/chant/op`'s
|
|
27
|
-
* `write-all
|
|
24
|
+
* `report` stays read-only, `issue`/`comment`/`pull-request` add the write
|
|
25
|
+
* scope the Op's own activity uses (`gh issue create` / a comment on the
|
|
26
|
+
* triggering PR / `gh pr create`, see `@intentius/chant/op`'s
|
|
27
|
+
* `reconcilePr` activity) — never a blanket `write-all`. `comment` is the
|
|
28
|
+
* one mode that constrains the trigger rather than only the scope: it
|
|
29
|
+
* needs a pull request to post onto, so this generator refuses it by name
|
|
30
|
+
* on any other trigger (#2231);
|
|
28
31
|
* - runs exactly one invocation, `chant run <name>` by default — never
|
|
29
32
|
* inlined audit/reconcile logic. The finding-mode itself is already baked
|
|
30
33
|
* into the Op's own activity args at build time by the composite that
|
|
31
|
-
* created it; this workflow only supplies the token the mode needs to act
|
|
34
|
+
* created it; this workflow only supplies the token the mode needs to act;
|
|
35
|
+
* - on a `push` trigger only, runs that invocation with `--gated-exit 0`
|
|
36
|
+
* and adds a follow-up job that says where the approval is pending
|
|
37
|
+
* (#2243). See {@link GATED_EXIT_FLAG} and {@link gateNoticeJob}.
|
|
38
|
+
*
|
|
39
|
+
* Two per-Op options widen that shape without loosening it (#2242). A spec's
|
|
40
|
+
* `setup` list emits steps between the checkout and the `beforeScript` lines,
|
|
41
|
+
* `uses:` steps included, which is the only way a generated job can reach an
|
|
42
|
+
* action like `aws-actions/configure-aws-credentials`; {@link
|
|
43
|
+
* assertSetupSteps} refuses an unpinned or default-branch ref at build time.
|
|
44
|
+
* A spec's `permissions` map is merged over {@link permissionsFor}, adding
|
|
45
|
+
* scopes the finding-mode never grants (`id-token: write` is the whole
|
|
46
|
+
* reason) and never touching one it does; {@link mergePermissions} refuses a
|
|
47
|
+
* blanket grant, an overlap with the mode's own set, an unknown scope name,
|
|
48
|
+
* and pull-request write on a trigger that has no pull request.
|
|
32
49
|
*/
|
|
33
|
-
import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
50
|
+
import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
34
51
|
export type { GenerateGithubOpOptions, GenerateGithubOpResult };
|
|
35
52
|
/**
|
|
36
53
|
* The structured pipeline document behind one generated file, before YAML
|
|
@@ -58,6 +75,15 @@ export interface GithubOpPipelineDoc {
|
|
|
58
75
|
permissions: Record<string, unknown>;
|
|
59
76
|
/** The `jobs:` mapping — one entry, this Op's trigger job. */
|
|
60
77
|
jobsDoc: Record<string, unknown>;
|
|
78
|
+
/**
|
|
79
|
+
* The gated-apply notice job (#2243), when this Op's trigger is `push`.
|
|
80
|
+
* Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
|
|
81
|
+
* simply not copying it: the job shells to `gh` against the GitHub API and
|
|
82
|
+
* needs `gh` on the runner, which is the same reason the `comment` finding
|
|
83
|
+
* mode is refused on forgejo and gitlab (#2231). {@link emitOpPipelineYAML}
|
|
84
|
+
* merges it into `jobs:` for the forges that can.
|
|
85
|
+
*/
|
|
86
|
+
gatedNoticeDoc?: Record<string, unknown>;
|
|
61
87
|
}
|
|
62
88
|
/** One generated file: a suggested name plus its pipeline document, pre-emission. */
|
|
63
89
|
export interface GithubOpPipelineFile {
|
|
@@ -66,10 +92,48 @@ export interface GithubOpPipelineFile {
|
|
|
66
92
|
doc: GithubOpPipelineDoc;
|
|
67
93
|
}
|
|
68
94
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* `
|
|
72
|
-
*
|
|
95
|
+
* Validate a spec's `setup` list (#2242). A `run` entry needs a non-empty
|
|
96
|
+
* line and nothing else. A `uses` entry has to be a pinned
|
|
97
|
+
* `owner/repo[/subpath]@ref`: no bare `owner/repo`, since an unpinned action
|
|
98
|
+
* resolves to its default branch, and no ref in {@link DEFAULT_BRANCH_REFS}
|
|
99
|
+
* for the same reason spelled out loud. Local (`./path`) and container
|
|
100
|
+
* (`docker://`) refs are refused too — they are legal GitHub Actions, but the
|
|
101
|
+
* generator emits a workflow into a repository it has never seen, so it
|
|
102
|
+
* cannot know a local path resolves there.
|
|
103
|
+
*/
|
|
104
|
+
export declare function assertSetupSteps(name: string, setup: OpSetupStep[]): void;
|
|
105
|
+
/**
|
|
106
|
+
* Merge a spec's additive `permissions` over the finding-mode's own set
|
|
107
|
+
* (#2242), refusing by name anything that is not strictly additive:
|
|
108
|
+
*
|
|
109
|
+
* - a blanket `write-all`/`read-all`, in either the key or the value
|
|
110
|
+
* position, which is the exact thing {@link permissionsForMode} exists to
|
|
111
|
+
* avoid;
|
|
112
|
+
* - a scope GitHub does not define ({@link GITHUB_TOKEN_SCOPES}), because
|
|
113
|
+
* GitHub ignores the key and the run silently gets nothing;
|
|
114
|
+
* - a scope the mode already grants, at any value — additive means additive,
|
|
115
|
+
* so this can neither downgrade `contents: write` to read nor restate it.
|
|
116
|
+
* A mode whose set is wrong is fixed by changing the mode, where the
|
|
117
|
+
* scope and the behavior that spends it stay together;
|
|
118
|
+
* - `pull-requests: write` on a trigger with no pull request. Pull-request
|
|
119
|
+
* access is what the finding-modes own: `pull-request` grants it together
|
|
120
|
+
* with the `contents: write` needed to push the branch first, and
|
|
121
|
+
* `comment` grants it on the one trigger that carries a pull request to
|
|
122
|
+
* comment on. Adding it beside a mode that posts nothing, on a cron or
|
|
123
|
+
* push run, grants write access no step in the generated job can spend.
|
|
124
|
+
*/
|
|
125
|
+
export declare function mergePermissions(name: string, base: Record<string, "read" | "write">, additive: Record<string, "read" | "write">, trigger: OpTrigger): Record<string, "read" | "write">;
|
|
126
|
+
/**
|
|
127
|
+
* Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
|
|
128
|
+
* steps, least-privilege `permissions:` for its finding-mode plus whatever
|
|
129
|
+
* the spec adds, one job that runs `chant run <name>`. Every
|
|
130
|
+
* `ScheduledOpSpec` is independent — unlike the component generator there is
|
|
131
|
+
* no shared graph to resolve — so the only thing this refuses is a spec that
|
|
132
|
+
* contradicts itself: no trigger at all (`resolveOpTrigger`), `findingMode:
|
|
133
|
+
* "comment"` on a trigger that has no pull request ({@link
|
|
134
|
+
* assertTriggerSupportsMode}), an unpinned `setup` action ({@link
|
|
135
|
+
* assertSetupSteps}), or a `permissions` entry that is not additive ({@link
|
|
136
|
+
* mergePermissions}).
|
|
73
137
|
*/
|
|
74
138
|
export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions): {
|
|
75
139
|
files: GithubOpPipelineFile[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EACT,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;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;;;;;;;OAOG;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;AAsQD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;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;AAiBD;;;;;;;;;;;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,CAiF1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAWnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;
|
|
1
|
+
{"version":3,"file":"pr-plan-report.d.ts","sourceRoot":"","sources":["../../src/composites/pr-plan-report.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAqB,MAAM,oBAAoB,CAAC;AAI5D,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC,qBAAqB,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACH;AAyBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY;;EAkEP,CAAC"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "3015db1776d8467a6769b316b4ffde3bbd225d53df528e97605b4b1645be3f27",
|
|
5
5
|
"meta.json": "dc3977afc2b6ddc4904de906d1b1e448b786b2aced9d82f8eade2ff915203344",
|
|
6
6
|
"types/index.d.ts": "f207946d7ab52f712d0c09995366440a6bba8b39358057b0da38ff9c0acca429",
|
|
7
7
|
"rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
"skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
|
|
78
78
|
"skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
|
|
79
79
|
},
|
|
80
|
-
"composite": "
|
|
80
|
+
"composite": "17318d4ba3cbdc98681abd0d398a4e5ea0c596397464c6622c53e6f2289669e4"
|
|
81
81
|
}
|
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.
|
|
3
|
+
"version": "0.59.0",
|
|
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,7 +61,7 @@
|
|
|
61
61
|
"typescript": "^5.9.3"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@intentius/chant": "^0.
|
|
64
|
+
"@intentius/chant": "^0.59.0",
|
|
65
65
|
"typescript": "^5.9.3"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (parses back via `../yaml.ts`'s `parseYAML`) with a `schedule` +
|
|
7
7
|
* `workflow_dispatch` trigger and one job.
|
|
8
8
|
* 2. `permissions:` is least-privilege per finding-mode — read-only for
|
|
9
|
-
* `report`, scoped write for `issue`/`pull-request`.
|
|
9
|
+
* `report`, scoped write for `issue`/`comment`/`pull-request`.
|
|
10
10
|
* 3. A cross-cutting generator change (extraScript/beforeScript/runCommand)
|
|
11
11
|
* is a single edit reflected in every generated file.
|
|
12
12
|
*/
|
|
@@ -18,6 +18,7 @@ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
|
18
18
|
|
|
19
19
|
interface ParsedStep {
|
|
20
20
|
name?: string;
|
|
21
|
+
id?: string;
|
|
21
22
|
uses?: string;
|
|
22
23
|
run?: string;
|
|
23
24
|
env?: Record<string, string>;
|
|
@@ -26,6 +27,10 @@ interface ParsedStep {
|
|
|
26
27
|
interface ParsedJob {
|
|
27
28
|
"runs-on"?: string;
|
|
28
29
|
container?: string;
|
|
30
|
+
needs?: string;
|
|
31
|
+
if?: string;
|
|
32
|
+
permissions?: Record<string, string>;
|
|
33
|
+
outputs?: Record<string, string>;
|
|
29
34
|
steps: ParsedStep[];
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -100,6 +105,23 @@ describe("generateGithubOpPipeline: least-privilege permissions per finding-mode
|
|
|
100
105
|
expect(doc.permissions).toEqual({ contents: "write", "pull-requests": "write" });
|
|
101
106
|
});
|
|
102
107
|
|
|
108
|
+
test("comment mode is exactly contents: read + pull-requests: write (#2231)", () => {
|
|
109
|
+
// The least-privilege set a plan-on-PR job wants, and the one no mode
|
|
110
|
+
// could produce before this one existed: `issue` adds `issues: write`,
|
|
111
|
+
// `pull-request` widens `contents` to write, `report` gets no forge write
|
|
112
|
+
// scope at all. `toEqual` is what makes this an exact set rather than a
|
|
113
|
+
// containment check.
|
|
114
|
+
const result = generateGithubOpPipeline([
|
|
115
|
+
{ name: "app-plan", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
|
|
116
|
+
]);
|
|
117
|
+
const doc = parseFile(result.files[0].yaml);
|
|
118
|
+
expect(doc.permissions).toEqual({ contents: "read", "pull-requests": "write" });
|
|
119
|
+
|
|
120
|
+
// The activity shells to `gh`, so the CLI's own token variable rides too.
|
|
121
|
+
const runStep = doc.jobs!["app-plan"].steps.find((s) => typeof s.run === "string")!;
|
|
122
|
+
expect(runStep.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}", GH_TOKEN: "${{ github.token }}" });
|
|
123
|
+
});
|
|
124
|
+
|
|
103
125
|
test("defaults to report (read-only) when findingMode is omitted", () => {
|
|
104
126
|
const result = generateGithubOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
|
|
105
127
|
expect(result.jobs[0].findingMode).toBe("report");
|
|
@@ -152,6 +174,24 @@ describe("generateGithubOpPipeline: trigger kinds (#2084)", () => {
|
|
|
152
174
|
expect(doc.permissions).toEqual({ contents: "read" });
|
|
153
175
|
});
|
|
154
176
|
|
|
177
|
+
test("comment mode is refused by name on a cron trigger (#2231)", () => {
|
|
178
|
+
const specs: ScheduledOpSpec[] = [
|
|
179
|
+
{ name: "app-plan", schedule: "0 6 * * *", findingMode: "comment" },
|
|
180
|
+
];
|
|
181
|
+
expect(() => generateGithubOpPipeline(specs)).toThrow(
|
|
182
|
+
/findingMode "comment".*trigger is "cron".*no pull request/s,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("comment mode is refused by name on a push trigger (#2231)", () => {
|
|
187
|
+
const specs: ScheduledOpSpec[] = [
|
|
188
|
+
{ name: "app-apply", trigger: { kind: "push", branches: ["main"] }, findingMode: "comment" },
|
|
189
|
+
];
|
|
190
|
+
expect(() => generateGithubOpPipeline(specs)).toThrow(
|
|
191
|
+
/Scheduled Op "app-apply".*findingMode "comment".*trigger is "push"/s,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
155
195
|
test("push trigger: filters to branches", () => {
|
|
156
196
|
const specs: ScheduledOpSpec[] = [
|
|
157
197
|
{ name: "tf-apply", trigger: { kind: "push", branches: ["release"] } },
|
|
@@ -257,3 +297,221 @@ describe("generateGithubOpPipeline: the Op's own schedule (#2120)", () => {
|
|
|
257
297
|
);
|
|
258
298
|
});
|
|
259
299
|
});
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The two per-Op options from #2242: `setup` steps between the checkout and
|
|
303
|
+
* the `beforeScript` lines, and `permissions` merged additively over the
|
|
304
|
+
* finding-mode's own set. Together they are what makes an OIDC job
|
|
305
|
+
* expressible — `aws-actions/configure-aws-credentials` is a `uses:` step,
|
|
306
|
+
* and no finding-mode grants `id-token: write`.
|
|
307
|
+
*/
|
|
308
|
+
describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)", () => {
|
|
309
|
+
const OIDC_SPEC: ScheduledOpSpec = {
|
|
310
|
+
name: "app-apply",
|
|
311
|
+
trigger: { kind: "push", branches: ["main"] },
|
|
312
|
+
setup: [
|
|
313
|
+
{
|
|
314
|
+
uses: "aws-actions/configure-aws-credentials@v6",
|
|
315
|
+
with: { "role-to-assume": "${{ vars.AWS_ROLE_ARN }}", "aws-region": "eu-west-1" },
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
permissions: { "id-token": "write" },
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
test("emits the action between the checkout and the beforeScript install", () => {
|
|
322
|
+
const result = generateGithubOpPipeline([OIDC_SPEC], { beforeScript: ["install terraform"] });
|
|
323
|
+
const doc = parseFile(result.files[0].yaml);
|
|
324
|
+
const steps = doc.jobs!["app-apply"].steps;
|
|
325
|
+
|
|
326
|
+
expect(steps.slice(0, 3).map((s) => s.uses ?? s.run)).toEqual([
|
|
327
|
+
"actions/checkout@v4",
|
|
328
|
+
"aws-actions/configure-aws-credentials@v6",
|
|
329
|
+
"install terraform",
|
|
330
|
+
]);
|
|
331
|
+
// The last step is the invocation. This spec's trigger is `push`, so it
|
|
332
|
+
// is the gated-apply script rather than a bare line (#2243); what this
|
|
333
|
+
// test owns is that the setup action lands between the checkout and the
|
|
334
|
+
// `beforeScript` install, whatever shape the invocation takes.
|
|
335
|
+
expect(steps).toHaveLength(4);
|
|
336
|
+
expect(steps[3].run).toContain("chant run app-apply");
|
|
337
|
+
expect((steps[1] as { with?: Record<string, string> }).with).toEqual({
|
|
338
|
+
"role-to-assume": "${{ vars.AWS_ROLE_ARN }}",
|
|
339
|
+
"aws-region": "eu-west-1",
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("adds id-token: write to the mode's own set without replacing it", () => {
|
|
344
|
+
const doc = parseFile(generateGithubOpPipeline([OIDC_SPEC]).files[0].yaml);
|
|
345
|
+
expect(doc.permissions).toEqual({ contents: "read", "id-token": "write" });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("carries a setup step's own `env` and emits a `run` entry as a plain step", () => {
|
|
349
|
+
const specs: ScheduledOpSpec[] = [
|
|
350
|
+
{
|
|
351
|
+
name: "app-apply",
|
|
352
|
+
schedule: "0 6 * * *",
|
|
353
|
+
setup: [{ run: "aws sts get-caller-identity", env: { AWS_REGION: "eu-west-1" } }],
|
|
354
|
+
},
|
|
355
|
+
];
|
|
356
|
+
const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
|
|
357
|
+
expect(steps[1]).toEqual({ run: "aws sts get-caller-identity", env: { AWS_REGION: "eu-west-1" } });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("refuses an action pinned to its own default branch", () => {
|
|
361
|
+
expect(() =>
|
|
362
|
+
generateGithubOpPipeline([{ ...OIDC_SPEC, setup: [{ uses: "aws-actions/configure-aws-credentials@main" }] }]),
|
|
363
|
+
).toThrow(/setup step 1 pins .* to "main", the action repository's own default branch/s);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("refuses an action with no ref at all", () => {
|
|
367
|
+
expect(() =>
|
|
368
|
+
generateGithubOpPipeline([{ ...OIDC_SPEC, setup: [{ uses: "aws-actions/configure-aws-credentials" }] }]),
|
|
369
|
+
).toThrow(/is not a pinned action reference/);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("accepts a subpath ref and a commit sha", () => {
|
|
373
|
+
const specs: ScheduledOpSpec[] = [
|
|
374
|
+
{
|
|
375
|
+
name: "app-apply",
|
|
376
|
+
schedule: "0 6 * * *",
|
|
377
|
+
setup: [
|
|
378
|
+
{ uses: "github/codeql-action/upload-sarif@v4" },
|
|
379
|
+
{ uses: "aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83" },
|
|
380
|
+
],
|
|
381
|
+
},
|
|
382
|
+
];
|
|
383
|
+
const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
|
|
384
|
+
expect(steps.map((s) => s.uses).filter(Boolean)).toEqual([
|
|
385
|
+
"actions/checkout@v4",
|
|
386
|
+
"github/codeql-action/upload-sarif@v4",
|
|
387
|
+
"aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83",
|
|
388
|
+
]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("refuses a blanket write-all", () => {
|
|
392
|
+
expect(() =>
|
|
393
|
+
generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { "write-all": "write" } }]),
|
|
394
|
+
).toThrow(/a blanket grant/);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("refuses widening a scope the finding-mode already grants", () => {
|
|
398
|
+
expect(() =>
|
|
399
|
+
generateGithubOpPipeline([
|
|
400
|
+
{ name: "prod-reconcile", schedule: "0 * * * *", findingMode: "issue", permissions: { issues: "write" } },
|
|
401
|
+
]),
|
|
402
|
+
).toThrow(/its finding-mode already grants "issues: write"/);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("refuses downgrading a scope the finding-mode already grants", () => {
|
|
406
|
+
expect(() =>
|
|
407
|
+
generateGithubOpPipeline([
|
|
408
|
+
{
|
|
409
|
+
name: "prod-reconcile",
|
|
410
|
+
schedule: "0 * * * *",
|
|
411
|
+
findingMode: "pull-request",
|
|
412
|
+
permissions: { contents: "read" },
|
|
413
|
+
},
|
|
414
|
+
]),
|
|
415
|
+
).toThrow(/its finding-mode already grants "contents: write"/);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("refuses a scope name GitHub does not define, which it would silently ignore", () => {
|
|
419
|
+
expect(() => generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { id_token: "write" } }])).toThrow(
|
|
420
|
+
/not a GITHUB_TOKEN permission scope/,
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("refuses pull-requests: write on a trigger that has no pull request", () => {
|
|
425
|
+
expect(() =>
|
|
426
|
+
generateGithubOpPipeline([{ ...OIDC_SPEC, permissions: { "pull-requests": "write" } }]),
|
|
427
|
+
).toThrow(/trigger is "push", which carries no pull request/);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("allows id-token: write beside the comment mode's own pull-request scope", () => {
|
|
431
|
+
const doc = parseFile(
|
|
432
|
+
generateGithubOpPipeline([
|
|
433
|
+
{
|
|
434
|
+
name: "app-plan",
|
|
435
|
+
trigger: { kind: "pull_request", branches: ["main"] },
|
|
436
|
+
findingMode: "comment",
|
|
437
|
+
permissions: { "id-token": "write" },
|
|
438
|
+
},
|
|
439
|
+
]).files[0].yaml,
|
|
440
|
+
);
|
|
441
|
+
expect(doc.permissions).toEqual({
|
|
442
|
+
contents: "read",
|
|
443
|
+
"pull-requests": "write",
|
|
444
|
+
"id-token": "write",
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* chant #2243 — a `push` job whose Op gates would otherwise be a red workflow
|
|
451
|
+
* run on every merge until someone approves. The mapping is `chant run`'s own
|
|
452
|
+
* (`--gated-exit 0`); what the generator adds is asking for it on the one
|
|
453
|
+
* trigger that needs it, and a job that says where the approval is pending.
|
|
454
|
+
*/
|
|
455
|
+
describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
|
|
456
|
+
const pushSpec: ScheduledOpSpec = { name: "app-apply", trigger: { kind: "push", branches: ["main"] } };
|
|
457
|
+
|
|
458
|
+
function pushDoc(): ParsedDoc {
|
|
459
|
+
return parseFile(generateGithubOpPipeline([pushSpec]).files[0].yaml);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
test("a push job runs with --gated-exit 0 and publishes what it stopped on", () => {
|
|
463
|
+
const doc = pushDoc();
|
|
464
|
+
const job = doc.jobs!["app-apply"];
|
|
465
|
+
const step = job.steps.find((s) => s.id === "chant-run");
|
|
466
|
+
expect(step?.run).toContain("chant run app-apply --gated-exit 0 --json");
|
|
467
|
+
expect(job.outputs).toEqual({
|
|
468
|
+
gated: "${{ steps.chant-run.outputs.gated }}",
|
|
469
|
+
op: "${{ steps.chant-run.outputs.op }}",
|
|
470
|
+
gate: "${{ steps.chant-run.outputs.gate }}",
|
|
471
|
+
approve: "${{ steps.chant-run.outputs.approve }}",
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("a cron watch and a pull_request plan keep the plain one-line invocation", () => {
|
|
476
|
+
for (const spec of [
|
|
477
|
+
{ name: "app-watch", schedule: "0 6 * * *" },
|
|
478
|
+
{ name: "app-plan", trigger: { kind: "pull_request" as const } },
|
|
479
|
+
] satisfies ScheduledOpSpec[]) {
|
|
480
|
+
const doc = parseFile(generateGithubOpPipeline([spec]).files[0].yaml);
|
|
481
|
+
const job = doc.jobs![spec.name];
|
|
482
|
+
expect(job.steps.some((s) => s.run?.includes("--gated-exit"))).toBe(false);
|
|
483
|
+
expect(job.outputs).toBeUndefined();
|
|
484
|
+
expect(doc.jobs![`${spec.name}-gate-notice`]).toBeUndefined();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("the notice job needs the apply, runs only on gated, and posts outside the log", () => {
|
|
489
|
+
const notice = pushDoc().jobs!["app-apply-gate-notice"];
|
|
490
|
+
expect(notice.needs).toBe("app-apply");
|
|
491
|
+
expect(notice.if).toBe("needs.app-apply.outputs.gated == 'true'");
|
|
492
|
+
// It shells to `gh`, which a hosted runner carries and the Op's own
|
|
493
|
+
// container image does not.
|
|
494
|
+
expect(notice.container).toBeUndefined();
|
|
495
|
+
const script = notice.steps[0].run ?? "";
|
|
496
|
+
expect(script).toContain('gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
|
|
497
|
+
expect(script).toContain('marker="<!-- chant-gate:$CHANT_OP -->"');
|
|
498
|
+
expect(script).toContain("gh issue create");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("the notice job's permissions are its two posting paths and the lookup", () => {
|
|
502
|
+
const notice = pushDoc().jobs!["app-apply-gate-notice"];
|
|
503
|
+
expect(notice.permissions).toEqual({
|
|
504
|
+
contents: "read",
|
|
505
|
+
issues: "write",
|
|
506
|
+
"pull-requests": "write",
|
|
507
|
+
});
|
|
508
|
+
// Job-level, so the apply beside it keeps the workflow's own read-only set.
|
|
509
|
+
expect(pushDoc().permissions).toEqual({ contents: "read" });
|
|
510
|
+
expect(pushDoc().jobs!["app-apply"].permissions).toBeUndefined();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("a failing run stays a failing job: the pipe cannot swallow its exit code", () => {
|
|
514
|
+
const step = pushDoc().jobs!["app-apply"].steps.find((s) => s.id === "chant-run");
|
|
515
|
+
expect(step?.run).toContain("set -o pipefail");
|
|
516
|
+
});
|
|
517
|
+
});
|
|
@@ -21,14 +21,31 @@
|
|
|
21
21
|
* branch);
|
|
22
22
|
* - declares only the `permissions:` its `findingMode` (and, for a
|
|
23
23
|
* `pull_request` trigger, whether that mode posts a comment) needs —
|
|
24
|
-
* `report` stays read-only, `issue`/`pull-request` add the write
|
|
25
|
-
* the Op's own activity uses (`gh issue create` /
|
|
26
|
-
* `@intentius/chant/op`'s
|
|
27
|
-
* `write-all
|
|
24
|
+
* `report` stays read-only, `issue`/`comment`/`pull-request` add the write
|
|
25
|
+
* scope the Op's own activity uses (`gh issue create` / a comment on the
|
|
26
|
+
* triggering PR / `gh pr create`, see `@intentius/chant/op`'s
|
|
27
|
+
* `reconcilePr` activity) — never a blanket `write-all`. `comment` is the
|
|
28
|
+
* one mode that constrains the trigger rather than only the scope: it
|
|
29
|
+
* needs a pull request to post onto, so this generator refuses it by name
|
|
30
|
+
* on any other trigger (#2231);
|
|
28
31
|
* - runs exactly one invocation, `chant run <name>` by default — never
|
|
29
32
|
* inlined audit/reconcile logic. The finding-mode itself is already baked
|
|
30
33
|
* into the Op's own activity args at build time by the composite that
|
|
31
|
-
* created it; this workflow only supplies the token the mode needs to act
|
|
34
|
+
* created it; this workflow only supplies the token the mode needs to act;
|
|
35
|
+
* - on a `push` trigger only, runs that invocation with `--gated-exit 0`
|
|
36
|
+
* and adds a follow-up job that says where the approval is pending
|
|
37
|
+
* (#2243). See {@link GATED_EXIT_FLAG} and {@link gateNoticeJob}.
|
|
38
|
+
*
|
|
39
|
+
* Two per-Op options widen that shape without loosening it (#2242). A spec's
|
|
40
|
+
* `setup` list emits steps between the checkout and the `beforeScript` lines,
|
|
41
|
+
* `uses:` steps included, which is the only way a generated job can reach an
|
|
42
|
+
* action like `aws-actions/configure-aws-credentials`; {@link
|
|
43
|
+
* assertSetupSteps} refuses an unpinned or default-branch ref at build time.
|
|
44
|
+
* A spec's `permissions` map is merged over {@link permissionsFor}, adding
|
|
45
|
+
* scopes the finding-mode never grants (`id-token: write` is the whole
|
|
46
|
+
* reason) and never touching one it does; {@link mergePermissions} refuses a
|
|
47
|
+
* blanket grant, an overlap with the mode's own set, an unknown scope name,
|
|
48
|
+
* and pull-request write on a trigger that has no pull request.
|
|
32
49
|
*/
|
|
33
50
|
|
|
34
51
|
import { emitYAML } from "@intentius/chant/yaml";
|
|
@@ -38,6 +55,7 @@ import type {
|
|
|
38
55
|
OpFindingMode,
|
|
39
56
|
OpPipelineJob,
|
|
40
57
|
OpPipelineResult as GenerateGithubOpResult,
|
|
58
|
+
OpSetupStep,
|
|
41
59
|
OpTrigger,
|
|
42
60
|
ScheduledOpSpec,
|
|
43
61
|
} from "@intentius/chant/lexicon";
|
|
@@ -70,6 +88,15 @@ export interface GithubOpPipelineDoc {
|
|
|
70
88
|
permissions: Record<string, unknown>;
|
|
71
89
|
/** The `jobs:` mapping — one entry, this Op's trigger job. */
|
|
72
90
|
jobsDoc: Record<string, unknown>;
|
|
91
|
+
/**
|
|
92
|
+
* The gated-apply notice job (#2243), when this Op's trigger is `push`.
|
|
93
|
+
* Kept out of {@link jobsDoc} so a dialect that cannot run it drops it by
|
|
94
|
+
* simply not copying it: the job shells to `gh` against the GitHub API and
|
|
95
|
+
* needs `gh` on the runner, which is the same reason the `comment` finding
|
|
96
|
+
* mode is refused on forgejo and gitlab (#2231). {@link emitOpPipelineYAML}
|
|
97
|
+
* merges it into `jobs:` for the forges that can.
|
|
98
|
+
*/
|
|
99
|
+
gatedNoticeDoc?: Record<string, unknown>;
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
/** One generated file: a suggested name plus its pipeline document, pre-emission. */
|
|
@@ -119,11 +146,16 @@ function onFor(trigger: OpTrigger): Record<string, unknown> {
|
|
|
119
146
|
* PR itself (#2084): any mode but `report` posts something to act on a
|
|
120
147
|
* finding, so on that trigger every such mode also gets `pull-requests:
|
|
121
148
|
* write` for the comment, whether or not its own scope already included it.
|
|
149
|
+
* `comment` is the mode that actually spends that grant (#2231), and it
|
|
150
|
+
* changes nothing in the repository, so its whole scope is `{ contents: read,
|
|
151
|
+
* pull-requests: write }`.
|
|
122
152
|
*/
|
|
123
153
|
function permissionsForMode(mode: OpFindingMode): Record<string, "read" | "write"> {
|
|
124
154
|
switch (mode) {
|
|
125
155
|
case "issue":
|
|
126
156
|
return { contents: "read", issues: "write" };
|
|
157
|
+
case "comment":
|
|
158
|
+
return { contents: "read", "pull-requests": "write" };
|
|
127
159
|
case "pull-request":
|
|
128
160
|
case "merge-request":
|
|
129
161
|
return { contents: "write", "pull-requests": "write" };
|
|
@@ -140,11 +172,332 @@ function permissionsFor(mode: OpFindingMode, trigger: OpTrigger): Record<string,
|
|
|
140
172
|
return base;
|
|
141
173
|
}
|
|
142
174
|
|
|
175
|
+
// ── The gated apply (#2243) ─────────────────────────────────────────────────
|
|
176
|
+
|
|
143
177
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
178
|
+
* `chant run` returns 3 when a run stops at an unapproved gate. GitHub Actions
|
|
179
|
+
* has no neutral conclusion for a `run:` step, so a push-to-main apply that
|
|
180
|
+
* gates paints the branch red on every merge until someone approves. This maps
|
|
181
|
+
* that one outcome to success, in chant rather than in a shell wrapper
|
|
182
|
+
* (#2243); a failed run still returns 1 and is still red.
|
|
183
|
+
*
|
|
184
|
+
* `push` only. A cron watch and a `pull_request` plan are never gated in a way
|
|
185
|
+
* that should be hidden: nobody is waiting on a merge for either, and a gated
|
|
186
|
+
* one there is a signal, not noise.
|
|
187
|
+
*/
|
|
188
|
+
const GATED_EXIT_FLAG = ["--gated-exit", "0"];
|
|
189
|
+
|
|
190
|
+
/** The id of the `chant run` step on a `push` job, so the job can publish its outputs. */
|
|
191
|
+
const RUN_STEP_ID = "chant-run";
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Turn the run's `--json` record into step outputs, so the notice job below
|
|
195
|
+
* has a condition to test and a gate to name. Runs in node, which is already
|
|
196
|
+
* on any machine `chant` runs on — unlike `jq`, which the Op's own container
|
|
197
|
+
* image need not carry.
|
|
198
|
+
*
|
|
199
|
+
* Nothing is written for a run that completed, so `gated` is either the string
|
|
200
|
+
* `true` or absent, and the notice job's `if:` is a plain equality.
|
|
201
|
+
*/
|
|
202
|
+
const GATE_OUTPUT_SCRIPT =
|
|
203
|
+
'const fs=require("fs");' +
|
|
204
|
+
'const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));' +
|
|
205
|
+
'if(r.status!=="gated"||!process.env.GITHUB_OUTPUT)process.exit(0);' +
|
|
206
|
+
"fs.appendFileSync(process.env.GITHUB_OUTPUT," +
|
|
207
|
+
'`gated=true\\nop=${r.op}\\ngate=${(r.gate&&r.gate.name)||""}\\napprove=${r.approve||""}\\n`)';
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The `push` job's run step: the invocation with {@link GATED_EXIT_FLAG} and
|
|
211
|
+
* `--json`, tee'd so the record is both in the log and on disk, then read for
|
|
212
|
+
* the job's outputs.
|
|
213
|
+
*
|
|
214
|
+
* `set -o pipefail` is not decoration. GitHub's default shell is `bash -e`,
|
|
215
|
+
* which does not set it, so a failing `chant run` piped into `tee` would come
|
|
216
|
+
* back as `tee`'s zero and turn a broken apply green — the exact thing this
|
|
217
|
+
* whole change must not do.
|
|
218
|
+
*/
|
|
219
|
+
function gatedRunScript(op: string, invocation: string): string {
|
|
220
|
+
return [
|
|
221
|
+
"set -o pipefail",
|
|
222
|
+
'json="${RUNNER_TEMP:-/tmp}/chant-run-' + op + '.json"',
|
|
223
|
+
`${invocation} | tee "$json"`,
|
|
224
|
+
`node -e '${GATE_OUTPUT_SCRIPT}' "$json"`,
|
|
225
|
+
].join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The notice body's `printf` format. Kept out of {@link gateNoticeScript} so
|
|
230
|
+
* the shell quoting stays readable: it is single-quoted in the emitted script
|
|
231
|
+
* because it carries markdown backticks, which a double-quoted shell string
|
|
232
|
+
* would run as command substitution.
|
|
233
|
+
*/
|
|
234
|
+
const NOTICE_BODY_FORMAT =
|
|
235
|
+
"%s\\n\\nThe `%s` apply for %s stopped at gate `%s` and is waiting for an approval. Nothing was applied." +
|
|
236
|
+
"\\n\\n```\\n%s --approver <you>\\n```\\n\\nThe pending fact is on `_gates/%s.jsonl` on the " +
|
|
237
|
+
"`chant/lifecycle` branch. Approving is a commit: push it and this workflow runs again and applies.\\n";
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What the notice job posts. The sticky-comment recipe `reconcilePr`'s
|
|
241
|
+
* `comment` mode already uses (#2231), spelled in shell because this job runs
|
|
242
|
+
* no Op: a hidden marker as the body's first line, found again with
|
|
243
|
+
* `startswith` on the next run, PATCHed when it is there and POSTed when it is
|
|
244
|
+
* not. So a branch that merges three times before anyone approves carries one
|
|
245
|
+
* comment saying what is pending, not three.
|
|
246
|
+
*
|
|
247
|
+
* A GitHub `push` event carries no pull request, so the target is looked up:
|
|
248
|
+
* `repos/{repo}/commits/{sha}/pulls` is the commit's own associated-pull-request
|
|
249
|
+
* endpoint, exact rather than a search index, and on a merge commit it answers
|
|
250
|
+
* with the pull request that just merged. When it answers with nothing — a
|
|
251
|
+
* direct push to the branch, a merge whose commit the API does not associate —
|
|
252
|
+
* the notice becomes an issue instead, which is the `issue` finding mode's own
|
|
253
|
+
* recipe and the reason this job carries `issues: write`.
|
|
254
|
+
*/
|
|
255
|
+
function gateNoticeScript(): string {
|
|
256
|
+
return [
|
|
257
|
+
'marker="<!-- chant-gate:$CHANT_OP -->"',
|
|
258
|
+
"body=$(printf '" + NOTICE_BODY_FORMAT + "' " +
|
|
259
|
+
'"$marker" "$CHANT_OP" "$GITHUB_SHA" "$CHANT_GATE" "$CHANT_APPROVE" "$CHANT_OP")',
|
|
260
|
+
'pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ".[0].number // empty")',
|
|
261
|
+
'if [ -z "$pr" ]; then',
|
|
262
|
+
' gh issue create --title "$CHANT_OP is waiting on gate $CHANT_GATE" --body "$body"',
|
|
263
|
+
" exit 0",
|
|
264
|
+
"fi",
|
|
265
|
+
'id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
|
|
266
|
+
'--jq "map(select(.body | startswith(\\"$marker\\"))) | .[0].id // empty" ' +
|
|
267
|
+
'| grep -m1 -E "^[0-9]+$" || true)',
|
|
268
|
+
'if [ -n "$id" ]; then',
|
|
269
|
+
' gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
|
|
270
|
+
"else",
|
|
271
|
+
' gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
|
|
272
|
+
"fi",
|
|
273
|
+
].join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The follow-up job: `needs:` the apply, runs only when the apply reported
|
|
278
|
+
* gated, and puts the pending state somewhere other than the Actions log.
|
|
279
|
+
*
|
|
280
|
+
* No `container:`. It needs `gh`, which GitHub-hosted runner images carry and
|
|
281
|
+
* an Op's own image (`node:22-slim` by default) does not; it reads nothing out
|
|
282
|
+
* of the repository, so it also needs no checkout.
|
|
283
|
+
*
|
|
284
|
+
* Its `permissions:` are its own, replacing the workflow-level set for this
|
|
285
|
+
* job alone: `contents: read` for the commit-to-pull-request lookup,
|
|
286
|
+
* `pull-requests: write` for the sticky comment, `issues: write` for the
|
|
287
|
+
* fallback when the push has no pull request. Nothing wider — it opens no
|
|
288
|
+
* branch and merges nothing.
|
|
289
|
+
*/
|
|
290
|
+
function gateNoticeJob(applyJobName: string): Record<string, unknown> {
|
|
291
|
+
const output = (name: string) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
|
|
292
|
+
return {
|
|
293
|
+
needs: applyJobName,
|
|
294
|
+
if: `needs.${applyJobName}.outputs.gated == 'true'`,
|
|
295
|
+
"runs-on": "ubuntu-latest",
|
|
296
|
+
permissions: { contents: "read", issues: "write", "pull-requests": "write" },
|
|
297
|
+
steps: [
|
|
298
|
+
{
|
|
299
|
+
name: "Report the pending gate",
|
|
300
|
+
env: {
|
|
301
|
+
GH_TOKEN: "${{ github.token }}",
|
|
302
|
+
GH_REPO: "${{ github.repository }}",
|
|
303
|
+
CHANT_OP: output("op"),
|
|
304
|
+
CHANT_GATE: output("gate"),
|
|
305
|
+
CHANT_APPROVE: output("approve"),
|
|
306
|
+
},
|
|
307
|
+
run: gateNoticeScript(),
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Refuse `findingMode: "comment"` on a trigger that has no pull request
|
|
315
|
+
* (#2231). The mode's activity reads the triggering PR out of the event
|
|
316
|
+
* payload at run time, so a cron- or push-triggered job carrying it would
|
|
317
|
+
* generate fine and then fail on every run. Refusing here names the Op, the
|
|
318
|
+
* mode and the trigger at build time instead.
|
|
319
|
+
*/
|
|
320
|
+
function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: OpTrigger): void {
|
|
321
|
+
if (mode !== "comment" || trigger.kind === "pull_request") return;
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Scheduled Op "${name}" has findingMode "comment", which posts its finding on the pull request that ` +
|
|
324
|
+
`triggered the run, but its trigger is "${trigger.kind}". A ${trigger.kind} run has no pull request ` +
|
|
325
|
+
`to comment on. Give it a { kind: "pull_request" } trigger, or use findingMode "issue".`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Every scope `GITHUB_TOKEN` accepts in a workflow's `permissions:` mapping,
|
|
331
|
+
* kebab-cased as GitHub spells them. An additive scope outside this set is
|
|
332
|
+
* refused by name rather than emitted: GitHub ignores an unknown key, so
|
|
333
|
+
* `id_token` or `idToken` would generate a workflow that looks like it grants
|
|
334
|
+
* OIDC and hands the run no token at all.
|
|
335
|
+
*/
|
|
336
|
+
const GITHUB_TOKEN_SCOPES = new Set([
|
|
337
|
+
"actions",
|
|
338
|
+
"attestations",
|
|
339
|
+
"checks",
|
|
340
|
+
"contents",
|
|
341
|
+
"deployments",
|
|
342
|
+
"discussions",
|
|
343
|
+
"id-token",
|
|
344
|
+
"issues",
|
|
345
|
+
"models",
|
|
346
|
+
"packages",
|
|
347
|
+
"pages",
|
|
348
|
+
"pull-requests",
|
|
349
|
+
"repository-projects",
|
|
350
|
+
"security-events",
|
|
351
|
+
"statuses",
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Refs that name an action repository's own default branch. A generated
|
|
356
|
+
* workflow is committed once and then re-run unattended, often over a cloud
|
|
357
|
+
* role, so "whatever was pushed to that repo last" is not a version — the
|
|
358
|
+
* code that assumes the role can change between the run somebody reviewed and
|
|
359
|
+
* the next one. A release channel the action's author cuts deliberately (`v6`,
|
|
360
|
+
* `v6.2.4`, `stable`) or a commit sha is a version, and both pass: this repo's
|
|
361
|
+
* own workflows pin `actions/checkout@v6` and `dtolnay/rust-toolchain@stable`
|
|
362
|
+
* and name no default branch anywhere.
|
|
363
|
+
*/
|
|
364
|
+
const DEFAULT_BRANCH_REFS = new Set(["main", "master", "head", "default"]);
|
|
365
|
+
|
|
366
|
+
/** `owner/repo` or `owner/repo/subpath`, then `@ref`. */
|
|
367
|
+
const USES_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)((?:\/[A-Za-z0-9_.-]+)*)@([^\s@]+)$/;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Validate a spec's `setup` list (#2242). A `run` entry needs a non-empty
|
|
371
|
+
* line and nothing else. A `uses` entry has to be a pinned
|
|
372
|
+
* `owner/repo[/subpath]@ref`: no bare `owner/repo`, since an unpinned action
|
|
373
|
+
* resolves to its default branch, and no ref in {@link DEFAULT_BRANCH_REFS}
|
|
374
|
+
* for the same reason spelled out loud. Local (`./path`) and container
|
|
375
|
+
* (`docker://`) refs are refused too — they are legal GitHub Actions, but the
|
|
376
|
+
* generator emits a workflow into a repository it has never seen, so it
|
|
377
|
+
* cannot know a local path resolves there.
|
|
378
|
+
*/
|
|
379
|
+
export function assertSetupSteps(name: string, setup: OpSetupStep[]): void {
|
|
380
|
+
setup.forEach((step, index) => {
|
|
381
|
+
const where = `Scheduled Op "${name}" setup step ${index + 1}`;
|
|
382
|
+
if ("uses" in step) {
|
|
383
|
+
const ref = step.uses.trim();
|
|
384
|
+
const match = USES_PATTERN.exec(ref);
|
|
385
|
+
if (!match) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`${where} has \`uses: "${step.uses}"\`, which is not a pinned action reference. ` +
|
|
388
|
+
`Write it as owner/repo@ref (optionally owner/repo/subpath@ref), e.g. ` +
|
|
389
|
+
`"aws-actions/configure-aws-credentials@v6". A local "./path" or "docker://" ref is not ` +
|
|
390
|
+
`accepted here: this generator emits a workflow into a repository it cannot inspect, so it ` +
|
|
391
|
+
`has no way to tell whether such a ref resolves there.`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const gitRef = match[4];
|
|
395
|
+
if (DEFAULT_BRANCH_REFS.has(gitRef.toLowerCase())) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`${where} pins \`uses: "${step.uses}"\` to "${gitRef}", the action repository's own default ` +
|
|
398
|
+
`branch, which names whatever was pushed there last rather than a version. A generated ` +
|
|
399
|
+
`workflow is committed once and re-run unattended, often over a cloud role, so pin a release ` +
|
|
400
|
+
`tag or a commit sha instead (e.g. "${match[1]}/${match[2]}@v1" or "@<40-char sha>").`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (step.run.trim() === "") {
|
|
406
|
+
throw new Error(`${where} has an empty \`run\` line. Give it a command, or drop the entry.`);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Merge a spec's additive `permissions` over the finding-mode's own set
|
|
413
|
+
* (#2242), refusing by name anything that is not strictly additive:
|
|
414
|
+
*
|
|
415
|
+
* - a blanket `write-all`/`read-all`, in either the key or the value
|
|
416
|
+
* position, which is the exact thing {@link permissionsForMode} exists to
|
|
417
|
+
* avoid;
|
|
418
|
+
* - a scope GitHub does not define ({@link GITHUB_TOKEN_SCOPES}), because
|
|
419
|
+
* GitHub ignores the key and the run silently gets nothing;
|
|
420
|
+
* - a scope the mode already grants, at any value — additive means additive,
|
|
421
|
+
* so this can neither downgrade `contents: write` to read nor restate it.
|
|
422
|
+
* A mode whose set is wrong is fixed by changing the mode, where the
|
|
423
|
+
* scope and the behavior that spends it stay together;
|
|
424
|
+
* - `pull-requests: write` on a trigger with no pull request. Pull-request
|
|
425
|
+
* access is what the finding-modes own: `pull-request` grants it together
|
|
426
|
+
* with the `contents: write` needed to push the branch first, and
|
|
427
|
+
* `comment` grants it on the one trigger that carries a pull request to
|
|
428
|
+
* comment on. Adding it beside a mode that posts nothing, on a cron or
|
|
429
|
+
* push run, grants write access no step in the generated job can spend.
|
|
430
|
+
*/
|
|
431
|
+
export function mergePermissions(
|
|
432
|
+
name: string,
|
|
433
|
+
base: Record<string, "read" | "write">,
|
|
434
|
+
additive: Record<string, "read" | "write">,
|
|
435
|
+
trigger: OpTrigger,
|
|
436
|
+
): Record<string, "read" | "write"> {
|
|
437
|
+
const merged: Record<string, "read" | "write"> = { ...base };
|
|
438
|
+
for (const [rawScope, value] of Object.entries(additive)) {
|
|
439
|
+
const scope = rawScope.trim();
|
|
440
|
+
const where = `Scheduled Op "${name}" adds permission "${scope}: ${value}"`;
|
|
441
|
+
if (scope === "write-all" || scope === "read-all" || String(value).endsWith("-all")) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`${where}, a blanket grant. \`permissions\` on a scheduled Op is additive over the ` +
|
|
444
|
+
`least-privilege set its finding-mode needs, one named scope at a time. Name the scopes the ` +
|
|
445
|
+
`job actually spends (e.g. { "id-token": "write" }).`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (!GITHUB_TOKEN_SCOPES.has(scope)) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`${where}, which is not a GITHUB_TOKEN permission scope. GitHub ignores an unrecognized key, so ` +
|
|
451
|
+
`this would emit a workflow that reads as granted and hands the run nothing. Known scopes: ` +
|
|
452
|
+
`${[...GITHUB_TOKEN_SCOPES].sort().join(", ")}.`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (scope in base) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
`${where}, but its finding-mode already grants "${scope}: ${base[scope]}". These permissions are ` +
|
|
458
|
+
`additive only — they never replace, widen or downgrade a scope the mode computed. Change the ` +
|
|
459
|
+
`Op's findingMode if that set is wrong, and add only scopes no mode grants (e.g. "id-token").`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (scope === "pull-requests" && trigger.kind !== "pull_request") {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`${where}, but this Op's trigger is "${trigger.kind}", which carries no pull request. Pull-request ` +
|
|
465
|
+
`write access belongs to a finding-mode: "pull-request" grants it with the contents: write its ` +
|
|
466
|
+
`branch push needs, and "comment" grants it on the pull_request trigger. Set findingMode instead ` +
|
|
467
|
+
`of adding the scope here.`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
merged[scope] = value;
|
|
471
|
+
}
|
|
472
|
+
return merged;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Emit one setup entry as a GitHub Actions step. */
|
|
476
|
+
function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
|
|
477
|
+
if ("uses" in step) {
|
|
478
|
+
return {
|
|
479
|
+
uses: step.uses,
|
|
480
|
+
...(step.with && Object.keys(step.with).length > 0 ? { with: step.with } : {}),
|
|
481
|
+
...(step.env && Object.keys(step.env).length > 0 ? { env: step.env } : {}),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
run: step.run,
|
|
486
|
+
...(step.env && Object.keys(step.env).length > 0 ? { env: step.env } : {}),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
|
|
492
|
+
* steps, least-privilege `permissions:` for its finding-mode plus whatever
|
|
493
|
+
* the spec adds, one job that runs `chant run <name>`. Every
|
|
494
|
+
* `ScheduledOpSpec` is independent — unlike the component generator there is
|
|
495
|
+
* no shared graph to resolve — so the only thing this refuses is a spec that
|
|
496
|
+
* contradicts itself: no trigger at all (`resolveOpTrigger`), `findingMode:
|
|
497
|
+
* "comment"` on a trigger that has no pull request ({@link
|
|
498
|
+
* assertTriggerSupportsMode}), an unpinned `setup` action ({@link
|
|
499
|
+
* assertSetupSteps}), or a `permissions` entry that is not additive ({@link
|
|
500
|
+
* mergePermissions}).
|
|
148
501
|
*/
|
|
149
502
|
export function buildGithubOpPipelineDocs(
|
|
150
503
|
ops: ScheduledOpSpec[],
|
|
@@ -161,6 +514,7 @@ export function buildGithubOpPipelineDocs(
|
|
|
161
514
|
for (const spec of ops) {
|
|
162
515
|
const findingMode = spec.findingMode ?? "report";
|
|
163
516
|
const trigger = resolveOpTrigger(spec);
|
|
517
|
+
assertTriggerSupportsMode(spec.name, findingMode, trigger);
|
|
164
518
|
const jobName = toJobName(spec.name);
|
|
165
519
|
jobs.push({ jobName, op: spec.name, trigger, findingMode });
|
|
166
520
|
|
|
@@ -171,9 +525,26 @@ export function buildGithubOpPipelineDocs(
|
|
|
171
525
|
const stepEnv: Record<string, string> = { GITHUB_TOKEN: "${{ github.token }}" };
|
|
172
526
|
if (findingMode !== "report") stepEnv.GH_TOKEN = "${{ github.token }}";
|
|
173
527
|
|
|
528
|
+
const setup = spec.setup ?? [];
|
|
529
|
+
assertSetupSteps(spec.name, setup);
|
|
530
|
+
|
|
531
|
+
// A `push` job is the one that has to survive a gate (#2243): the apply
|
|
532
|
+
// runs with `--gated-exit 0` so a pending approval is a green run, and
|
|
533
|
+
// publishes what it stopped on as job outputs for the notice job below.
|
|
534
|
+
// Every other trigger keeps the plain one-line invocation it always had.
|
|
535
|
+
const gated = trigger.kind === "push";
|
|
536
|
+
const invocation = gated
|
|
537
|
+
? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
|
|
538
|
+
: runParts.join(" ");
|
|
539
|
+
|
|
174
540
|
const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
|
|
541
|
+
for (const step of setup) steps.push(setupStepDoc(step));
|
|
175
542
|
for (const line of beforeScript) steps.push({ run: line });
|
|
176
|
-
steps.push(
|
|
543
|
+
steps.push(
|
|
544
|
+
gated
|
|
545
|
+
? { id: RUN_STEP_ID, run: gatedRunScript(spec.name, invocation), env: stepEnv }
|
|
546
|
+
: { run: invocation, env: stepEnv },
|
|
547
|
+
);
|
|
177
548
|
for (const line of extraScript) steps.push({ run: line });
|
|
178
549
|
|
|
179
550
|
const doc: GithubOpPipelineDoc = {
|
|
@@ -182,14 +553,30 @@ export function buildGithubOpPipelineDocs(
|
|
|
182
553
|
// One run at a time per Op — a slow audit must not overlap its own next
|
|
183
554
|
// scheduled trigger.
|
|
184
555
|
concurrency: { group: jobName, "cancel-in-progress": false },
|
|
185
|
-
permissions:
|
|
556
|
+
permissions: mergePermissions(
|
|
557
|
+
spec.name,
|
|
558
|
+
permissionsFor(findingMode, trigger),
|
|
559
|
+
spec.permissions ?? {},
|
|
560
|
+
trigger,
|
|
561
|
+
),
|
|
186
562
|
jobsDoc: {
|
|
187
563
|
[jobName]: {
|
|
188
564
|
"runs-on": "ubuntu-latest",
|
|
189
565
|
container: image,
|
|
566
|
+
...(gated
|
|
567
|
+
? {
|
|
568
|
+
outputs: Object.fromEntries(
|
|
569
|
+
["gated", "op", "gate", "approve"].map((name) => [
|
|
570
|
+
name,
|
|
571
|
+
`\${{ steps.${RUN_STEP_ID}.outputs.${name} }}`,
|
|
572
|
+
]),
|
|
573
|
+
),
|
|
574
|
+
}
|
|
575
|
+
: {}),
|
|
190
576
|
steps,
|
|
191
577
|
},
|
|
192
578
|
},
|
|
579
|
+
...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName) } } : {}),
|
|
193
580
|
};
|
|
194
581
|
|
|
195
582
|
files.push({ name: `${spec.name}.yml`, doc });
|
|
@@ -209,7 +596,10 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
|
|
|
209
596
|
if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
|
|
210
597
|
sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
|
|
211
598
|
if (Object.keys(doc.permissions).length > 0) sections.push("permissions:" + emitYAML(doc.permissions, 1));
|
|
212
|
-
|
|
599
|
+
// The gated-apply notice job rides in `jobs:` beside the Op's own job, but
|
|
600
|
+
// is carried separately on the doc so a dialect that cannot run it (forgejo,
|
|
601
|
+
// whose runner has no `gh` pointed at its own instance) drops it by omission.
|
|
602
|
+
sections.push("jobs:" + emitYAML({ ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }, 1));
|
|
213
603
|
return sections.join("\n\n") + "\n";
|
|
214
604
|
}
|
|
215
605
|
|
|
@@ -79,6 +79,28 @@ describe("PrPlanReport composite (#1983)", () => {
|
|
|
79
79
|
expect(Math.max(...credIndexes)).toBeLessThan(planIndex);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
// #2236 — `gh api`'s `-F/--field` is the flag that expands a leading `@`
|
|
83
|
+
// into the file's contents; `-f/--raw-field` adds the parameter as a literal
|
|
84
|
+
// string, so the `-f` form this composite shipped with posted a comment
|
|
85
|
+
// whose body was the eight characters `@plan.md`. Nothing asserted the flag,
|
|
86
|
+
// which is how it survived, so both the emitted script and the serialized
|
|
87
|
+
// workflow are pinned here.
|
|
88
|
+
test("the sticky-comment script reads the body with -F, never -f (#2236)", () => {
|
|
89
|
+
const { job } = PrPlanReport({ environment: "prod" });
|
|
90
|
+
const postStep = steps(job).find((s) => s.props.name === "Post or update PR comment")!;
|
|
91
|
+
const run = postStep.props.run!;
|
|
92
|
+
expect(run).toContain('gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -F body=@plan.md');
|
|
93
|
+
expect(run).toContain('gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md');
|
|
94
|
+
expect(run).not.toContain("-f body=@");
|
|
95
|
+
// The plan step writes the marker as plan.md's first line, so the body the
|
|
96
|
+
// -F read now starts with `$MARKER` and the jq `startswith` search finds
|
|
97
|
+
// the comment on the next push. Under `-f` it never could: the body was
|
|
98
|
+
// `@plan.md`, so every run posted a new comment instead of patching.
|
|
99
|
+
const planStep = steps(job).find((s) => s.props.name === "Plan prod")!;
|
|
100
|
+
expect(planStep.props.run).toContain('{ printf \'%s\\n\\n\' "$MARKER";');
|
|
101
|
+
expect(planStep.props.run).toContain("> plan.md");
|
|
102
|
+
});
|
|
103
|
+
|
|
82
104
|
test("the emitted workflow passes the github lexicon's own lint — no errors, pinned actions included", () => {
|
|
83
105
|
const { job } = PrPlanReport({ environment: "prod", before: ["aws sts get-caller-identity"] });
|
|
84
106
|
const workflow = new Workflow({
|
|
@@ -90,6 +112,10 @@ describe("PrPlanReport composite (#1983)", () => {
|
|
|
90
112
|
) as SerializerResult;
|
|
91
113
|
const yaml = typeof result === "string" ? result : result.primary!;
|
|
92
114
|
expect(yaml).toContain("Post or update PR comment");
|
|
115
|
+
// The flag survives serialization too, not just the composite's script
|
|
116
|
+
// string (#2236).
|
|
117
|
+
expect(yaml).toContain("-F body=@plan.md");
|
|
118
|
+
expect(yaml).not.toContain("-f body=@");
|
|
93
119
|
|
|
94
120
|
const ctx: PostSynthContext = {
|
|
95
121
|
outputs: new Map([["github", yaml]]),
|
|
@@ -49,17 +49,22 @@ export interface PrPlanReportProps {
|
|
|
49
49
|
* Sticky-comment script (#1223's mechanism, reused as-is): find the comment
|
|
50
50
|
* whose body starts with `$MARKER`, PATCH it if found, POST otherwise. No
|
|
51
51
|
* marketplace action, nothing extra to pin — `gh` ships on GitHub's hosted
|
|
52
|
-
* runners.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* runners. The flag is `-F`, not `-f`: `gh api`'s `-F/--field` is the typed
|
|
53
|
+
* form that reads the value from a file when it starts with `@`, while
|
|
54
|
+
* `-f/--raw-field` adds the parameter as a literal string, so the `-f` form
|
|
55
|
+
* posted the eight characters `@plan.md` (#2236). Reading the body from the
|
|
56
|
+
* file the plan step wrote means a large or multi-line plan never has to
|
|
57
|
+
* survive shell quoting. `-F`'s type coercion of `true`/`false`/`null`/
|
|
58
|
+
* integers does not reach the body: gh resolves the leading `@` first and
|
|
59
|
+
* hands back the file's bytes as a string.
|
|
55
60
|
*/
|
|
56
61
|
const stickyCommentScript = [
|
|
57
62
|
'comment_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate ' +
|
|
58
63
|
'--jq "map(select(.body | startswith(\\"$MARKER\\"))) | .[0].id // empty")',
|
|
59
64
|
'if [ -n "$comment_id" ]; then',
|
|
60
|
-
' gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -
|
|
65
|
+
' gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -F body=@plan.md > /dev/null',
|
|
61
66
|
"else",
|
|
62
|
-
' gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -
|
|
67
|
+
' gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md > /dev/null',
|
|
63
68
|
"fi",
|
|
64
69
|
].join("\n");
|
|
65
70
|
|