@intentius/chant-lexicon-github 0.61.0 → 0.62.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 +29 -1
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/composites/pr-plan-report.d.ts +3 -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 +105 -1
- package/src/components/generate-op-pipeline.ts +97 -14
- package/src/composites/pr-plan-report.test.ts +26 -2
- package/src/composites/pr-plan-report.ts +35 -15
|
@@ -57,6 +57,17 @@
|
|
|
57
57
|
* environment protection is repository configuration, not a token scope — and
|
|
58
58
|
* {@link assertEnvironment} refuses only what would emit as configured and
|
|
59
59
|
* bind nothing.
|
|
60
|
+
*
|
|
61
|
+
* A fourth is per-Op credentials (#2290): a spec's `variables` emits the Op's
|
|
62
|
+
* own job-level `env:`, beside (and layered over, on a key collision)
|
|
63
|
+
* `options.variables`'s workflow-level `env:`. `options.variables` keeps
|
|
64
|
+
* meaning what it always has — set once, landing on every generated file — so
|
|
65
|
+
* a caller who declares nothing per-Op sees byte-identical output; a spec's
|
|
66
|
+
* own `variables` is the way one Op's job can hold a credential no other Op's
|
|
67
|
+
* job receives, which a workflow-level declaration can never express because
|
|
68
|
+
* every generated file here has exactly the one Op job (plus, on a `push`
|
|
69
|
+
* trigger, the gate-notice job beside it — job-level `env:` does not reach
|
|
70
|
+
* that job either, same as `environment:` above does not).
|
|
60
71
|
*/
|
|
61
72
|
import type { ComponentPipelineOptions as GenerateGithubOpOptions, OpEnvironment, OpPipelineJob, OpPipelineResult as GenerateGithubOpResult, OpSetupStep, OpTrigger, ScheduledOpSpec } from "@intentius/chant/lexicon";
|
|
62
73
|
export type { GenerateGithubOpOptions, GenerateGithubOpResult };
|
|
@@ -157,6 +168,23 @@ export declare function assertEnvironment(name: string, environment: OpEnvironme
|
|
|
157
168
|
* push run, grants write access no step in the generated job can spend.
|
|
158
169
|
*/
|
|
159
170
|
export declare function mergePermissions(name: string, base: Record<string, "read" | "write">, additive: Record<string, "read" | "write">, trigger: OpTrigger): Record<string, "read" | "write">;
|
|
171
|
+
/**
|
|
172
|
+
* Internal knobs beside the public {@link GenerateGithubOpOptions} — not part
|
|
173
|
+
* of that type because they are not something a project author sets, only
|
|
174
|
+
* something a dialect on top of this builder (forgejo, #2294) needs to
|
|
175
|
+
* change about the shape this function emits for every spec alike.
|
|
176
|
+
*/
|
|
177
|
+
export interface BuildGithubOpPipelineDocsInternalOptions {
|
|
178
|
+
/**
|
|
179
|
+
* Whether a `push` job's outputs (`gated`/`op`/`gate`/`approve`) and the
|
|
180
|
+
* `node -e` step that writes them to `$GITHUB_OUTPUT` are emitted (#2294).
|
|
181
|
+
* Default true (github's own behavior, unchanged): the gate-notice job
|
|
182
|
+
* beside it reads them. The forgejo generator passes `false` — it never
|
|
183
|
+
* carries a gate-notice job (`gatedNoticeDoc` never crosses the dialect),
|
|
184
|
+
* so nothing would ever read them there.
|
|
185
|
+
*/
|
|
186
|
+
emitGatedOutputs?: boolean;
|
|
187
|
+
}
|
|
160
188
|
/**
|
|
161
189
|
* Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
|
|
162
190
|
* steps, least-privilege `permissions:` for its finding-mode plus whatever
|
|
@@ -169,7 +197,7 @@ export declare function mergePermissions(name: string, base: Record<string, "rea
|
|
|
169
197
|
* assertSetupSteps}), or a `permissions` entry that is not additive ({@link
|
|
170
198
|
* mergePermissions}).
|
|
171
199
|
*/
|
|
172
|
-
export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions): {
|
|
200
|
+
export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions, internalOptions?: BuildGithubOpPipelineDocsInternalOptions): {
|
|
173
201
|
files: GithubOpPipelineFile[];
|
|
174
202
|
jobs: OpPipelineJob[];
|
|
175
203
|
};
|
|
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,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;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;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;;;;;;;;;OASG;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;AAqTD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;GAKG;AACH,MAAM,WAAW,wCAAwC;IACvD;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,EACrC,eAAe,GAAE,wCAA6C,GAC7D;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CA6F1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
|
|
@@ -51,7 +51,9 @@ export interface PrPlanReportProps {
|
|
|
51
51
|
* proved (#1223), reused here rather than reinvented. The forgejo lexicon
|
|
52
52
|
* inherits this composite through its github re-export, since Forgejo Actions
|
|
53
53
|
* runs the same workflow shape and the Forgejo API accepts the same `gh api`
|
|
54
|
-
* calls against its GitHub-compatible surface
|
|
54
|
+
* calls against its GitHub-compatible surface — once they target a full URL
|
|
55
|
+
* rather than a bare relative path, which `stickyCommentScript` now does
|
|
56
|
+
* (#2305, the same fix #2291 made for `reconcilePr`'s `postOrUpdateComment`).
|
|
55
57
|
*
|
|
56
58
|
* Only meaningful on a `pull_request`-triggered workflow — the job guards on
|
|
57
59
|
* `github.event_name` since the comment targets `github.event.number`, which
|
|
@@ -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;AA2CD;;;;;;;;;;;;;;;;;;;GAmBG;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": "1c9c51090ae4df5488b7a558b098177d5f40d9efccc4ab8b89bb5caf91d1b953",
|
|
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": "92101a972617cf05fe02d8c0ff00f3ef87c8b769e768e1da63a6ed7730df17af"
|
|
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.62.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.62.0",
|
|
65
65
|
"typescript": "^5.9.3"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -21,6 +21,7 @@ interface ParsedStep {
|
|
|
21
21
|
id?: string;
|
|
22
22
|
uses?: string;
|
|
23
23
|
run?: string;
|
|
24
|
+
shell?: string;
|
|
24
25
|
env?: Record<string, string>;
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -32,6 +33,7 @@ interface ParsedJob {
|
|
|
32
33
|
if?: string;
|
|
33
34
|
permissions?: Record<string, string>;
|
|
34
35
|
outputs?: Record<string, string>;
|
|
36
|
+
env?: Record<string, string>;
|
|
35
37
|
steps: ParsedStep[];
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -494,11 +496,31 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
|
|
|
494
496
|
// container image does not.
|
|
495
497
|
expect(notice.container).toBeUndefined();
|
|
496
498
|
const script = notice.steps[0].run ?? "";
|
|
497
|
-
expect(script).toContain('gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
|
|
499
|
+
expect(script).toContain('gh api "$api_base/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
|
|
498
500
|
expect(script).toContain('marker="<!-- chant-gate:$CHANT_OP -->"');
|
|
499
501
|
expect(script).toContain("gh issue create");
|
|
500
502
|
});
|
|
501
503
|
|
|
504
|
+
// chant #2305 — same anti-pattern `stickyCommentScript`
|
|
505
|
+
// (`lexicons/github/src/composites/pr-plan-report.ts`) and `reconcilePr`'s
|
|
506
|
+
// `postOrUpdateComment` (#2291) had: a bare relative path, which `gh`
|
|
507
|
+
// resolves against `/api/v3` for any host but github.com. This job never
|
|
508
|
+
// reaches Forgejo today (the forgejo generator never carries
|
|
509
|
+
// `gatedNoticeDoc` across the dialect, #2294), so the fix changes nothing
|
|
510
|
+
// observable on github.com or GHES — it only stops a future Forgejo notice
|
|
511
|
+
// job from inheriting the same bug.
|
|
512
|
+
test("the notice job's gh api calls resolve their base from $GITHUB_API_URL, not a bare path (#2305)", () => {
|
|
513
|
+
const script = pushDoc().jobs!["app-apply-gate-notice"].steps[0].run ?? "";
|
|
514
|
+
expect(script).toContain('api_base="${GITHUB_API_URL%/}"');
|
|
515
|
+
expect(script).toContain('api_base="${api_base:-https://api.github.com}"');
|
|
516
|
+
expect(script).toContain('gh api "$api_base/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"');
|
|
517
|
+
expect(script).toContain('gh api "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments"');
|
|
518
|
+
expect(script).toContain('gh api --method PATCH "$api_base/repos/$GITHUB_REPOSITORY/issues/comments/$id"');
|
|
519
|
+
expect(script).toContain('gh api --method POST "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments"');
|
|
520
|
+
// No bare-path call survives.
|
|
521
|
+
expect(script).not.toMatch(/gh api[^\n]*"repos\//);
|
|
522
|
+
});
|
|
523
|
+
|
|
502
524
|
test("the notice job's permissions are its two posting paths and the lookup", () => {
|
|
503
525
|
const notice = pushDoc().jobs!["app-apply-gate-notice"];
|
|
504
526
|
expect(notice.permissions).toEqual({
|
|
@@ -515,6 +537,24 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
|
|
|
515
537
|
const step = pushDoc().jobs!["app-apply"].steps.find((s) => s.id === "chant-run");
|
|
516
538
|
expect(step?.run).toContain("set -o pipefail");
|
|
517
539
|
});
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* chant #2299 — every Op job carries `container:` (this generator sets one
|
|
543
|
+
* unconditionally, default `node:22-slim`), and a container job's default
|
|
544
|
+
* shell on GitHub Actions is `sh`, not bash. `sh` rejects `set -o pipefail`
|
|
545
|
+
* outright (`Illegal option -o pipefail`) and fails the step before `chant`
|
|
546
|
+
* is ever reached, which is exactly what happened on a real run (choudoufu
|
|
547
|
+
* #1026, run 34312967579). The step that emits `pipefail` must declare
|
|
548
|
+
* `shell: bash` itself.
|
|
549
|
+
*/
|
|
550
|
+
test("the pipefail step declares shell: bash — its job runs in a container, whose default shell is sh (#2299)", () => {
|
|
551
|
+
const doc = pushDoc();
|
|
552
|
+
const job = doc.jobs!["app-apply"];
|
|
553
|
+
expect(job.container).toBe("node:22-slim");
|
|
554
|
+
const step = job.steps.find((s) => s.id === "chant-run");
|
|
555
|
+
expect(step?.run).toContain("set -o pipefail");
|
|
556
|
+
expect(step?.shell).toBe("bash");
|
|
557
|
+
});
|
|
518
558
|
});
|
|
519
559
|
|
|
520
560
|
/**
|
|
@@ -642,3 +682,67 @@ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2
|
|
|
642
682
|
).toThrow(/has an empty `url`/);
|
|
643
683
|
});
|
|
644
684
|
});
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* chant #2290 — per-Op credentials, so a pull-request job need not hold the
|
|
688
|
+
* apply credential. `variables` lands as the Op's own job-level `env:`, one
|
|
689
|
+
* level more specific than `options.variables`'s workflow-level `env:`, and
|
|
690
|
+
* never on the gate-notice job beside it.
|
|
691
|
+
*/
|
|
692
|
+
describe("generateGithubOpPipeline: per-Op variables on the job (#2290)", () => {
|
|
693
|
+
const CREDENTIAL = { AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" };
|
|
694
|
+
|
|
695
|
+
test("a spec with no variables emits no job-level env:, even when options.variables is set", () => {
|
|
696
|
+
const doc = parseFile(
|
|
697
|
+
generateGithubOpPipeline([{ name: "live-check", trigger: { kind: "pull_request", branches: ["main"] } }], {
|
|
698
|
+
variables: { CHANT_FORGE: "github" },
|
|
699
|
+
}).files[0].yaml,
|
|
700
|
+
);
|
|
701
|
+
expect(doc.jobs!["live-check"].env).toBeUndefined();
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("a spec's own variables land as the job's env:, beside the workflow env:", () => {
|
|
705
|
+
const doc = parseFile(
|
|
706
|
+
generateGithubOpPipeline(
|
|
707
|
+
[{ name: "live-apply", trigger: { kind: "push", branches: ["main"] }, variables: CREDENTIAL }],
|
|
708
|
+
{ variables: { CHANT_FORGE: "github" } },
|
|
709
|
+
).files[0].yaml,
|
|
710
|
+
);
|
|
711
|
+
expect(doc.jobs!["live-apply"].env).toEqual(CREDENTIAL);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("a per-Op key wins over a same-named forge-wide one", () => {
|
|
715
|
+
const doc = parseFile(
|
|
716
|
+
generateGithubOpPipeline(
|
|
717
|
+
[
|
|
718
|
+
{
|
|
719
|
+
name: "live-apply",
|
|
720
|
+
trigger: { kind: "push", branches: ["main"] },
|
|
721
|
+
variables: { CHANT_FORGE: "overridden" },
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
{ variables: { CHANT_FORGE: "github" } },
|
|
725
|
+
).files[0].yaml,
|
|
726
|
+
);
|
|
727
|
+
expect(doc.jobs!["live-apply"].env).toEqual({ CHANT_FORGE: "overridden" });
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("the gate-notice job never carries the Op's own job-level variables", () => {
|
|
731
|
+
const doc = parseFile(
|
|
732
|
+
generateGithubOpPipeline([
|
|
733
|
+
{ name: "live-apply", trigger: { kind: "push", branches: ["main"] }, variables: CREDENTIAL },
|
|
734
|
+
]).files[0].yaml,
|
|
735
|
+
);
|
|
736
|
+
expect(doc.jobs!["live-apply-gate-notice"].env).toBeUndefined();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("a spec declaring variables changes exactly the job's env: block and nothing else", () => {
|
|
740
|
+
const spec: ScheduledOpSpec = { name: "actions-audit", schedule: "0 6 * * *" };
|
|
741
|
+
const before = generateGithubOpPipeline([spec]).files[0].yaml;
|
|
742
|
+
const after = generateGithubOpPipeline([{ ...spec, variables: CREDENTIAL }]).files[0].yaml;
|
|
743
|
+
expect(after).toContain(" env:\n AWS_ACCESS_KEY_ID: '${{ secrets.AWS_ACCESS_KEY_ID }}'\n");
|
|
744
|
+
expect(
|
|
745
|
+
after.replace(" env:\n AWS_ACCESS_KEY_ID: '${{ secrets.AWS_ACCESS_KEY_ID }}'\n", ""),
|
|
746
|
+
).toBe(before);
|
|
747
|
+
});
|
|
748
|
+
});
|
|
@@ -57,6 +57,17 @@
|
|
|
57
57
|
* environment protection is repository configuration, not a token scope — and
|
|
58
58
|
* {@link assertEnvironment} refuses only what would emit as configured and
|
|
59
59
|
* bind nothing.
|
|
60
|
+
*
|
|
61
|
+
* A fourth is per-Op credentials (#2290): a spec's `variables` emits the Op's
|
|
62
|
+
* own job-level `env:`, beside (and layered over, on a key collision)
|
|
63
|
+
* `options.variables`'s workflow-level `env:`. `options.variables` keeps
|
|
64
|
+
* meaning what it always has — set once, landing on every generated file — so
|
|
65
|
+
* a caller who declares nothing per-Op sees byte-identical output; a spec's
|
|
66
|
+
* own `variables` is the way one Op's job can hold a credential no other Op's
|
|
67
|
+
* job receives, which a workflow-level declaration can never express because
|
|
68
|
+
* every generated file here has exactly the one Op job (plus, on a `push`
|
|
69
|
+
* trigger, the gate-notice job beside it — job-level `env:` does not reach
|
|
70
|
+
* that job either, same as `environment:` above does not).
|
|
60
71
|
*/
|
|
61
72
|
|
|
62
73
|
import { emitYAML } from "@intentius/chant/yaml";
|
|
@@ -234,18 +245,50 @@ const GATE_OUTPUT_SCRIPT =
|
|
|
234
245
|
* `--json`, tee'd so the record is both in the log and on disk, then read for
|
|
235
246
|
* the job's outputs.
|
|
236
247
|
*
|
|
237
|
-
* `set -o pipefail` is not decoration.
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
248
|
+
* `set -o pipefail` is not decoration. Without it, a failing `chant run`
|
|
249
|
+
* piped into `tee` comes back as `tee`'s own zero and turns a broken apply
|
|
250
|
+
* green — the exact thing this whole change must not do. But `pipefail` is a
|
|
251
|
+
* bash-ism: GitHub's (and Forgejo's) default shell for a job with no
|
|
252
|
+
* `container:` is `bash --noprofile --norc -eo pipefail`, which already sets
|
|
253
|
+
* it, but the default for a job that *does* carry `container:` — which every
|
|
254
|
+
* Op job does, this one included (#2299) — is plain `sh`, which rejects `set
|
|
255
|
+
* -o pipefail` outright (`Illegal option -o pipefail`) and fails the step
|
|
256
|
+
* before `chant` ever runs. This generator's own Op job always sets
|
|
257
|
+
* `container:` (see {@link buildGithubOpPipelineDocs}'s `container: image`),
|
|
258
|
+
* so the failure is not a corner case — it is what every gated apply and
|
|
259
|
+
* gated adopt does today.
|
|
260
|
+
*
|
|
261
|
+
* That is why this function returns the whole step record rather than just
|
|
262
|
+
* the script text: `shell: "bash"` is bundled into the same return value as
|
|
263
|
+
* the `pipefail` line, so nothing that calls it can get one without the
|
|
264
|
+
* other. A future edit that inlines another `pipefail`-bearing script has to
|
|
265
|
+
* either reuse this function or open a new call site that a reviewer can see
|
|
266
|
+
* declares no shell — the property that made #2299 possible in the first
|
|
267
|
+
* place is no longer available by accident.
|
|
268
|
+
*
|
|
269
|
+
* The `node -e` line and the job's own `outputs:` mapping exist for exactly
|
|
270
|
+
* one reader: the gate-notice job beside it, which is `needs:`-only readable
|
|
271
|
+
* through `steps.<id>.outputs`/`needs.<job>.outputs`. `emitOutputs: false`
|
|
272
|
+
* (forgejo, #2294 — the notice job does not cross the dialect at all) drops
|
|
273
|
+
* that line: nothing on Forgejo reads `$GITHUB_OUTPUT` for this job, and
|
|
274
|
+
* keeping the line would be a wired-up mechanism with no reader, which is the
|
|
275
|
+
* exact thing #2294 is about. The tee'd invocation survives either way — it's
|
|
276
|
+
* what puts the run's own JSON record in the log, gate-notice job or not.
|
|
241
277
|
*/
|
|
242
|
-
function
|
|
243
|
-
|
|
278
|
+
function gatedRunStep(
|
|
279
|
+
id: string,
|
|
280
|
+
op: string,
|
|
281
|
+
invocation: string,
|
|
282
|
+
emitOutputs: boolean,
|
|
283
|
+
env: Record<string, string>,
|
|
284
|
+
): Record<string, unknown> {
|
|
285
|
+
const lines = [
|
|
244
286
|
"set -o pipefail",
|
|
245
287
|
'json="${RUNNER_TEMP:-/tmp}/chant-run-' + op + '.json"',
|
|
246
288
|
`${invocation} | tee "$json"`,
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
];
|
|
290
|
+
if (emitOutputs) lines.push(`node -e '${GATE_OUTPUT_SCRIPT}' "$json"`);
|
|
291
|
+
return { id, run: lines.join("\n"), shell: "bash", env };
|
|
249
292
|
}
|
|
250
293
|
|
|
251
294
|
/**
|
|
@@ -274,24 +317,39 @@ const NOTICE_BODY_FORMAT =
|
|
|
274
317
|
* direct push to the branch, a merge whose commit the API does not associate —
|
|
275
318
|
* the notice becomes an issue instead, which is the `issue` finding mode's own
|
|
276
319
|
* recipe and the reason this job carries `issues: write`.
|
|
320
|
+
*
|
|
321
|
+
* `$api_base` (chant #2305), resolved the same way `stickyCommentScript`
|
|
322
|
+
* (`lexicons/github/src/composites/pr-plan-report.ts`) and `reconcilePr`'s
|
|
323
|
+
* `githubApiBaseFrom` (`packages/core/src/op/activities/reconcile.ts`)
|
|
324
|
+
* resolve it: `$GITHUB_API_URL` with a trailing slash trimmed, falling back
|
|
325
|
+
* to `https://api.github.com`. This job never crosses to Forgejo today — the
|
|
326
|
+
* forgejo generator's `gatedNoticeDoc` is never carried across the dialect
|
|
327
|
+
* (#2294) — so github.com and GitHub Enterprise Server are the only hosts
|
|
328
|
+
* that run this script, and both already resolved correctly under `gh`'s own
|
|
329
|
+
* bare-path guess. Built from `$api_base` instead, the URL is byte-identical
|
|
330
|
+
* on those two hosts, and the same anti-pattern that broke Forgejo elsewhere
|
|
331
|
+
* (#2291, #2305) does not get a second copy here in case a future notice job
|
|
332
|
+
* does reach Forgejo.
|
|
277
333
|
*/
|
|
278
334
|
function gateNoticeScript(): string {
|
|
279
335
|
return [
|
|
336
|
+
'api_base="${GITHUB_API_URL%/}"',
|
|
337
|
+
'api_base="${api_base:-https://api.github.com}"',
|
|
280
338
|
'marker="<!-- chant-gate:$CHANT_OP -->"',
|
|
281
339
|
"body=$(printf '" + NOTICE_BODY_FORMAT + "' " +
|
|
282
340
|
'"$marker" "$CHANT_OP" "$GITHUB_SHA" "$CHANT_GATE" "$CHANT_APPROVE" "$CHANT_OP")',
|
|
283
|
-
'pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ".[0].number // empty")',
|
|
341
|
+
'pr=$(gh api "$api_base/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq ".[0].number // empty")',
|
|
284
342
|
'if [ -z "$pr" ]; then',
|
|
285
343
|
' gh issue create --title "$CHANT_OP is waiting on gate $CHANT_GATE" --body "$body"',
|
|
286
344
|
" exit 0",
|
|
287
345
|
"fi",
|
|
288
|
-
'id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
|
|
346
|
+
'id=$(gh api "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
|
|
289
347
|
'--jq "map(select(.body | startswith(\\"$marker\\"))) | .[0].id // empty" ' +
|
|
290
348
|
'| grep -m1 -E "^[0-9]+$" || true)',
|
|
291
349
|
'if [ -n "$id" ]; then',
|
|
292
|
-
' gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
|
|
350
|
+
' gh api --method PATCH "$api_base/repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
|
|
293
351
|
"else",
|
|
294
|
-
' gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
|
|
352
|
+
' gh api --method POST "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
|
|
295
353
|
"fi",
|
|
296
354
|
].join("\n");
|
|
297
355
|
}
|
|
@@ -559,6 +617,24 @@ function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
|
|
|
559
617
|
};
|
|
560
618
|
}
|
|
561
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Internal knobs beside the public {@link GenerateGithubOpOptions} — not part
|
|
622
|
+
* of that type because they are not something a project author sets, only
|
|
623
|
+
* something a dialect on top of this builder (forgejo, #2294) needs to
|
|
624
|
+
* change about the shape this function emits for every spec alike.
|
|
625
|
+
*/
|
|
626
|
+
export interface BuildGithubOpPipelineDocsInternalOptions {
|
|
627
|
+
/**
|
|
628
|
+
* Whether a `push` job's outputs (`gated`/`op`/`gate`/`approve`) and the
|
|
629
|
+
* `node -e` step that writes them to `$GITHUB_OUTPUT` are emitted (#2294).
|
|
630
|
+
* Default true (github's own behavior, unchanged): the gate-notice job
|
|
631
|
+
* beside it reads them. The forgejo generator passes `false` — it never
|
|
632
|
+
* carries a gate-notice job (`gatedNoticeDoc` never crosses the dialect),
|
|
633
|
+
* so nothing would ever read them there.
|
|
634
|
+
*/
|
|
635
|
+
emitGatedOutputs?: boolean;
|
|
636
|
+
}
|
|
637
|
+
|
|
562
638
|
/**
|
|
563
639
|
* Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
|
|
564
640
|
* steps, least-privilege `permissions:` for its finding-mode plus whatever
|
|
@@ -574,7 +650,9 @@ function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
|
|
|
574
650
|
export function buildGithubOpPipelineDocs(
|
|
575
651
|
ops: ScheduledOpSpec[],
|
|
576
652
|
options: GenerateGithubOpOptions = {},
|
|
653
|
+
internalOptions: BuildGithubOpPipelineDocsInternalOptions = {},
|
|
577
654
|
): { files: GithubOpPipelineFile[]; jobs: OpPipelineJob[] } {
|
|
655
|
+
const emitGatedOutputs = internalOptions.emitGatedOutputs ?? true;
|
|
578
656
|
const image = options.image ?? DEFAULT_IMAGE;
|
|
579
657
|
const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
|
|
580
658
|
const beforeScript = options.beforeScript ?? [];
|
|
@@ -615,7 +693,7 @@ export function buildGithubOpPipelineDocs(
|
|
|
615
693
|
for (const line of beforeScript) steps.push({ run: line });
|
|
616
694
|
steps.push(
|
|
617
695
|
gated
|
|
618
|
-
?
|
|
696
|
+
? gatedRunStep(RUN_STEP_ID, spec.name, invocation, emitGatedOutputs, stepEnv)
|
|
619
697
|
: { run: invocation, env: stepEnv },
|
|
620
698
|
);
|
|
621
699
|
for (const line of extraScript) steps.push({ run: line });
|
|
@@ -636,12 +714,17 @@ export function buildGithubOpPipelineDocs(
|
|
|
636
714
|
[jobName]: {
|
|
637
715
|
"runs-on": "ubuntu-latest",
|
|
638
716
|
container: image,
|
|
717
|
+
// Per-Op credentials (#2290): job-level `env:`, one level more
|
|
718
|
+
// specific than `options.variables`'s workflow-level `env:` above,
|
|
719
|
+
// and never on the notice job beside it — same reason `environment:`
|
|
720
|
+
// isn't either, it's this Op's own job alone.
|
|
721
|
+
...(spec.variables && Object.keys(spec.variables).length > 0 ? { env: spec.variables } : {}),
|
|
639
722
|
// On this Op's own job and never on the notice job beside it: the
|
|
640
723
|
// notice exists to say a chant gate is pending, and putting it
|
|
641
724
|
// behind the same reviewer would hold the message back until
|
|
642
725
|
// somebody had already acted.
|
|
643
726
|
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
644
|
-
...(gated
|
|
727
|
+
...(gated && emitGatedOutputs
|
|
645
728
|
? {
|
|
646
729
|
outputs: Object.fromEntries(
|
|
647
730
|
["gated", "op", "gate", "approve"].map((name) => [
|
|
@@ -89,8 +89,8 @@ describe("PrPlanReport composite (#1983)", () => {
|
|
|
89
89
|
const { job } = PrPlanReport({ environment: "prod" });
|
|
90
90
|
const postStep = steps(job).find((s) => s.props.name === "Post or update PR comment")!;
|
|
91
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');
|
|
92
|
+
expect(run).toContain('gh api -X PATCH "$api_base/repos/$REPO/issues/comments/$comment_id" -F body=@plan.md');
|
|
93
|
+
expect(run).toContain('gh api -X POST "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md');
|
|
94
94
|
expect(run).not.toContain("-f body=@");
|
|
95
95
|
// The plan step writes the marker as plan.md's first line, so the body the
|
|
96
96
|
// -F read now starts with `$MARKER` and the jq `startswith` search finds
|
|
@@ -101,6 +101,30 @@ describe("PrPlanReport composite (#1983)", () => {
|
|
|
101
101
|
expect(planStep.props.run).toContain("> plan.md");
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
// chant #2305 — this script's three `gh api` calls used to take a bare
|
|
105
|
+
// relative path (`repos/$REPO/...`), which `gh` resolves against `/api/v3`
|
|
106
|
+
// for any host but github.com. GitHub Enterprise Server serves that path,
|
|
107
|
+
// so the bug was invisible there; Forgejo does not, and answers 404 for
|
|
108
|
+
// GET and POST alike (settled against a real instance in #2291/#2304, the
|
|
109
|
+
// same fix this composite gets here). Every call now targets `$api_base`,
|
|
110
|
+
// built from `$GITHUB_API_URL` the same way `githubApiBaseFrom`
|
|
111
|
+
// (`packages/core/src/op/activities/reconcile.ts`) reads it for
|
|
112
|
+
// `reconcilePr`, with the github.com fallback inlined since this is a shell
|
|
113
|
+
// string emitted into YAML rather than a TypeScript call.
|
|
114
|
+
test("the sticky-comment script resolves its API base from $GITHUB_API_URL, not a bare path (#2305)", () => {
|
|
115
|
+
const { job } = PrPlanReport({ environment: "prod" });
|
|
116
|
+
const postStep = steps(job).find((s) => s.props.name === "Post or update PR comment")!;
|
|
117
|
+
const run = postStep.props.run!;
|
|
118
|
+
expect(run).toContain('api_base="${GITHUB_API_URL%/}"');
|
|
119
|
+
expect(run).toContain('api_base="${api_base:-https://api.github.com}"');
|
|
120
|
+
expect(run).toContain('gh api "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" --paginate');
|
|
121
|
+
expect(run).toContain('gh api -X PATCH "$api_base/repos/$REPO/issues/comments/$comment_id"');
|
|
122
|
+
expect(run).toContain('gh api -X POST "$api_base/repos/$REPO/issues/$PR_NUMBER/comments"');
|
|
123
|
+
// No bare-path call survives: every `gh api` invocation is immediately
|
|
124
|
+
// followed by `"$api_base/repos/`, never by `"repos/` on its own.
|
|
125
|
+
expect(run).not.toMatch(/gh api[^\n]*"repos\//);
|
|
126
|
+
});
|
|
127
|
+
|
|
104
128
|
test("the emitted workflow passes the github lexicon's own lint — no errors, pinned actions included", () => {
|
|
105
129
|
const { job } = PrPlanReport({ environment: "prod", before: ["aws sts get-caller-identity"] });
|
|
106
130
|
const workflow = new Workflow({
|
|
@@ -46,25 +46,43 @@ export interface PrPlanReportProps {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Sticky-comment script (#1223's mechanism,
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
49
|
+
* Sticky-comment script (#1223's mechanism), find the comment whose body
|
|
50
|
+
* starts with `$MARKER`, PATCH it if found, POST otherwise. No marketplace
|
|
51
|
+
* action, nothing extra to pin — `gh` ships on GitHub's hosted runners. The
|
|
52
|
+
* flag is `-F`, not `-f`: `gh api`'s `-F/--field` is the typed form that
|
|
53
|
+
* reads the value from a file when it starts with `@`, while `-f/--raw-field`
|
|
54
|
+
* adds the parameter as a literal string, so the `-f` form posted the eight
|
|
55
|
+
* characters `@plan.md` (#2236). Reading the body from the file the plan step
|
|
56
|
+
* wrote means a large or multi-line plan never has to survive shell quoting.
|
|
57
|
+
* `-F`'s type coercion of `true`/`false`/`null`/integers does not reach the
|
|
58
|
+
* body: gh resolves the leading `@` first and hands back the file's bytes as
|
|
59
|
+
* a string.
|
|
60
|
+
*
|
|
61
|
+
* `$api_base` (chant #2305) is `$GITHUB_API_URL` with any trailing slash
|
|
62
|
+
* trimmed, falling back to `https://api.github.com` when it is unset —
|
|
63
|
+
* `$GITHUB_API_URL` is already correct on every GitHub Actions and Forgejo
|
|
64
|
+
* Actions job (`https://api.github.com` on github.com, `<host>/api/v3` on
|
|
65
|
+
* GitHub Enterprise Server, `<host>/api/v1` on Forgejo), the same fact
|
|
66
|
+
* `githubApiBaseFrom` in `packages/core/src/op/activities/reconcile.ts`
|
|
67
|
+
* builds on for `reconcilePr`'s `postOrUpdateComment` (#2291). This script
|
|
68
|
+
* cannot import that helper — it is a shell string emitted into YAML, not a
|
|
69
|
+
* TypeScript call — so the equivalent base-URL construction is inlined here
|
|
70
|
+
* instead. Without it, every `gh api` call below took a bare relative path,
|
|
71
|
+
* which `gh` resolves against `/api/v3` for any host but github.com; that
|
|
72
|
+
* guess is right for GitHub Enterprise Server but wrong for Forgejo, which
|
|
73
|
+
* does not serve `/api/v3` and answered 404 for GET and POST alike. On
|
|
74
|
+
* github.com and GHES the resolved URL is byte-identical to `gh`'s own
|
|
75
|
+
* guess, so this changes nothing there.
|
|
60
76
|
*/
|
|
61
77
|
const stickyCommentScript = [
|
|
62
|
-
'
|
|
78
|
+
'api_base="${GITHUB_API_URL%/}"',
|
|
79
|
+
'api_base="${api_base:-https://api.github.com}"',
|
|
80
|
+
'comment_id=$(gh api "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" --paginate ' +
|
|
63
81
|
'--jq "map(select(.body | startswith(\\"$MARKER\\"))) | .[0].id // empty")',
|
|
64
82
|
'if [ -n "$comment_id" ]; then',
|
|
65
|
-
' gh api -X PATCH "repos/$REPO/issues/comments/$comment_id" -F body=@plan.md > /dev/null',
|
|
83
|
+
' gh api -X PATCH "$api_base/repos/$REPO/issues/comments/$comment_id" -F body=@plan.md > /dev/null',
|
|
66
84
|
"else",
|
|
67
|
-
' gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md > /dev/null',
|
|
85
|
+
' gh api -X POST "$api_base/repos/$REPO/issues/$PR_NUMBER/comments" -F body=@plan.md > /dev/null',
|
|
68
86
|
"fi",
|
|
69
87
|
].join("\n");
|
|
70
88
|
|
|
@@ -79,7 +97,9 @@ const stickyCommentScript = [
|
|
|
79
97
|
* proved (#1223), reused here rather than reinvented. The forgejo lexicon
|
|
80
98
|
* inherits this composite through its github re-export, since Forgejo Actions
|
|
81
99
|
* runs the same workflow shape and the Forgejo API accepts the same `gh api`
|
|
82
|
-
* calls against its GitHub-compatible surface
|
|
100
|
+
* calls against its GitHub-compatible surface — once they target a full URL
|
|
101
|
+
* rather than a bare relative path, which `stickyCommentScript` now does
|
|
102
|
+
* (#2305, the same fix #2291 made for `reconcilePr`'s `postOrUpdateComment`).
|
|
83
103
|
*
|
|
84
104
|
* Only meaningful on a `pull_request`-triggered workflow — the job guards on
|
|
85
105
|
* `github.event_name` since the comment targets `github.event.number`, which
|