@intentius/chant-lexicon-github 0.121.1 → 0.123.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 +5 -23
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.d.ts +4 -0
- package/dist/components/generate-op-waves-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.notice.test.ts +149 -0
- package/src/components/generate-op-pipeline.test.ts +34 -28
- package/src/components/generate-op-pipeline.ts +94 -102
- package/src/components/generate-op-waves-pipeline.test.ts +24 -0
- package/src/components/generate-op-waves-pipeline.ts +8 -1
- package/src/components/generate-pipeline.test.ts +1 -1
- package/src/components/generate-pipeline.ts +1 -1
- package/src/composites/pr-plan-report.test.ts +14 -1
- package/src/composites/pr-plan-report.ts +14 -1
|
@@ -114,11 +114,10 @@ export interface GithubOpPipelineDoc {
|
|
|
114
114
|
jobsDoc: Record<string, unknown>;
|
|
115
115
|
/**
|
|
116
116
|
* The gated-apply notice job (#2243), when this Op's trigger is `push`.
|
|
117
|
-
* Kept out of {@link jobsDoc} so a dialect
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* into `jobs:` for the forges that can. GitLab reaches the same outcome
|
|
117
|
+
* Kept out of {@link jobsDoc} so a dialect transforms it on its own terms;
|
|
118
|
+
* the forgejo generator carries it across since #3773, when the job moved
|
|
119
|
+
* off `gh` onto `@intentius/chant/pr-forge`. {@link emitOpPipelineYAML}
|
|
120
|
+
* merges it into `jobs:`. GitLab reaches the same outcome
|
|
122
121
|
* without this job at all: its push job writes the pending block to an
|
|
123
122
|
* artifact instead (#2256).
|
|
124
123
|
*/
|
|
@@ -174,23 +173,6 @@ export declare function assertEnvironment(name: string, environment: OpEnvironme
|
|
|
174
173
|
* push run, grants write access no step in the generated job can spend.
|
|
175
174
|
*/
|
|
176
175
|
export declare function mergePermissions(name: string, base: Record<string, "read" | "write">, additive: Record<string, "read" | "write">, trigger: OpTrigger): Record<string, "read" | "write">;
|
|
177
|
-
/**
|
|
178
|
-
* Internal knobs beside the public {@link GenerateGithubOpOptions} — not part
|
|
179
|
-
* of that type because they are not something a project author sets, only
|
|
180
|
-
* something a dialect on top of this builder (forgejo, #2294) needs to
|
|
181
|
-
* change about the shape this function emits for every spec alike.
|
|
182
|
-
*/
|
|
183
|
-
export interface BuildGithubOpPipelineDocsInternalOptions {
|
|
184
|
-
/**
|
|
185
|
-
* Whether a `push` job's outputs (`gated`/`op`/`gate`/`approve`) and the
|
|
186
|
-
* `node -e` step that writes them to `$GITHUB_OUTPUT` are emitted (#2294).
|
|
187
|
-
* Default true (github's own behavior, unchanged): the gate-notice job
|
|
188
|
-
* beside it reads them. The forgejo generator passes `false` — it never
|
|
189
|
-
* carries a gate-notice job (`gatedNoticeDoc` never crosses the dialect),
|
|
190
|
-
* so nothing would ever read them there.
|
|
191
|
-
*/
|
|
192
|
-
emitGatedOutputs?: boolean;
|
|
193
|
-
}
|
|
194
176
|
/**
|
|
195
177
|
* Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
|
|
196
178
|
* steps, least-privilege `permissions:` for its finding-mode plus whatever
|
|
@@ -203,7 +185,7 @@ export interface BuildGithubOpPipelineDocsInternalOptions {
|
|
|
203
185
|
* assertSetupSteps}), or a `permissions` entry that is not additive ({@link
|
|
204
186
|
* mergePermissions}).
|
|
205
187
|
*/
|
|
206
|
-
export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions
|
|
188
|
+
export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions): {
|
|
207
189
|
files: GithubOpPipelineFile[];
|
|
208
190
|
jobs: OpPipelineJob[];
|
|
209
191
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;
|
|
1
|
+
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAKH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EAET,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAgVD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAiG1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* `pull-requests: read`, so a wave can read the merged pull request's
|
|
24
24
|
* reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
|
|
25
25
|
* --record-plans` on each pull request to record the digests its head plans.
|
|
26
|
+
*
|
|
27
|
+
* With `notify` on the spec (#3761), the workflow maps each variable it names
|
|
28
|
+
* from the repository secret of the same name, so a wave job can post to
|
|
29
|
+
* Slack, Teams or a signed webhook. `options.variables` wins over the mapping.
|
|
26
30
|
*/
|
|
27
31
|
import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
28
32
|
import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,EAAoC,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClG,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,wBAAwB,CAAC;AAEhC,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAY,CAAC;AAExC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAOD,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAkEpI;AA4CD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
|
|
@@ -1 +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;AAqDD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,YAAY;;EAqEP,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": "ddb4b5dde531f86c72dfd39f31cf9a136cac1502167761af71c68c30cf7a456d",
|
|
5
5
|
"meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
|
|
6
6
|
"types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
|
|
7
7
|
"rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
|
|
@@ -78,5 +78,5 @@
|
|
|
78
78
|
"skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
|
|
79
79
|
"skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
|
|
80
80
|
},
|
|
81
|
-
"composite": "
|
|
81
|
+
"composite": "8ce3c463bf312052392409997e3971a57aee545fec0127b566c4200c425900e5"
|
|
82
82
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.123.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.123.0",
|
|
65
65
|
"typescript": "^5.9.3"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #3773: the gate-notice job's script, run.
|
|
3
|
+
*
|
|
4
|
+
* The generator emits a node script that posts through
|
|
5
|
+
* `@intentius/chant/pr-forge`. These tests take that script out of the
|
|
6
|
+
* generated workflow, run it with tsx against this checkout's chant, the way
|
|
7
|
+
* the job runs it against the release it installs, and answer its calls from
|
|
8
|
+
* a fake forge: GitHub's shapes at an `api.github.com`-style base, Forgejo's
|
|
9
|
+
* at `/api/v1`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { createServer, type IncomingMessage, type Server } from "node:http";
|
|
15
|
+
import type { AddressInfo } from "node:net";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { promisify } from "node:util";
|
|
20
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
21
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
22
|
+
import { generateGithubOpPipeline } from "./generate-op-pipeline";
|
|
23
|
+
|
|
24
|
+
const run = promisify(execFile);
|
|
25
|
+
const REPO = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
26
|
+
const SHA = "0123456789abcdef0123456789abcdef01234567";
|
|
27
|
+
|
|
28
|
+
interface Call {
|
|
29
|
+
method: string;
|
|
30
|
+
url: string;
|
|
31
|
+
auth?: string;
|
|
32
|
+
body?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The node script inside the generated notice step's heredoc. */
|
|
36
|
+
function noticeScript(): string {
|
|
37
|
+
const yaml = generateGithubOpPipeline([{ name: "app-apply", trigger: { kind: "push", branches: ["main"] } }]).files[0].yaml;
|
|
38
|
+
const doc = parseYAML(yaml) as { jobs: Record<string, { steps: Array<{ run?: string }> }> };
|
|
39
|
+
const shell = doc.jobs["app-apply-gate-notice"].steps[0].run ?? "";
|
|
40
|
+
const m = /cat > notice\.mjs <<'NOTICE'\n([\s\S]*?)\nNOTICE\n/.exec(shell);
|
|
41
|
+
if (!m) throw new Error("no notice.mjs heredoc in the notice step");
|
|
42
|
+
return m[1];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dirs: string[] = [];
|
|
46
|
+
|
|
47
|
+
/** A directory laid out like the job's: notice.mjs, and chant and tsx under node_modules. */
|
|
48
|
+
function jobDir(): string {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-gate-notice-"));
|
|
50
|
+
dirs.push(dir);
|
|
51
|
+
mkdirSync(join(dir, "node_modules", "@intentius"), { recursive: true });
|
|
52
|
+
symlinkSync(join(REPO, "packages", "core"), join(dir, "node_modules", "@intentius", "chant"));
|
|
53
|
+
symlinkSync(join(REPO, "node_modules", "tsx"), join(dir, "node_modules", "tsx"));
|
|
54
|
+
writeFileSync(join(dir, "notice.mjs"), noticeScript());
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let text = "";
|
|
61
|
+
req.on("data", (c) => (text += c));
|
|
62
|
+
req.on("end", () => resolve(text ? (JSON.parse(text) as Record<string, unknown>) : undefined));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let server: Server | undefined;
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
server?.close();
|
|
69
|
+
server = undefined;
|
|
70
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** A fake forge answering `routes` by `METHOD path`, query string dropped. */
|
|
74
|
+
async function fakeForge(routes: Record<string, (body?: Record<string, unknown>) => [number, unknown]>): Promise<{ base: string; calls: Call[] }> {
|
|
75
|
+
const calls: Call[] = [];
|
|
76
|
+
server = createServer(async (req, res) => {
|
|
77
|
+
const body = await readBody(req);
|
|
78
|
+
const path = (req.url ?? "").split("?")[0];
|
|
79
|
+
calls.push({ method: req.method ?? "", url: req.url ?? "", auth: req.headers.authorization, ...(body ? { body } : {}) });
|
|
80
|
+
const route = routes[`${req.method} ${path}`];
|
|
81
|
+
const [status, answer] = route ? route(body) : [404, { message: "not found" }];
|
|
82
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
83
|
+
res.end(JSON.stringify(answer));
|
|
84
|
+
});
|
|
85
|
+
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
|
|
86
|
+
return { base: `http://127.0.0.1:${(server!.address() as AddressInfo).port}`, calls };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runNotice(env: Record<string, string>): Promise<string> {
|
|
90
|
+
const { stdout } = await run(process.execPath, ["--import", "tsx", "notice.mjs"], {
|
|
91
|
+
cwd: jobDir(),
|
|
92
|
+
env: {
|
|
93
|
+
PATH: process.env.PATH ?? "",
|
|
94
|
+
TSX_DISABLE_CACHE: "1",
|
|
95
|
+
GITHUB_ACTIONS: "true",
|
|
96
|
+
GITHUB_REPOSITORY: "acme/infra",
|
|
97
|
+
GITHUB_SHA: SHA,
|
|
98
|
+
GITHUB_TOKEN: "job-token",
|
|
99
|
+
CHANT_OP: "app-apply",
|
|
100
|
+
CHANT_GATE: "apply",
|
|
101
|
+
CHANT_APPROVE: "chant approve app-apply apply",
|
|
102
|
+
...env,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
return stdout.trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("the gate-notice script (#3773)", () => {
|
|
109
|
+
test("on GitHub it comments on the pull request the commit merged", async () => {
|
|
110
|
+
const { base, calls } = await fakeForge({
|
|
111
|
+
[`GET /repos/acme/infra/commits/${SHA}/pulls`]: () => [200, [{ number: 7, merged_at: "2026-10-11T00:00:00Z", merge_commit_sha: SHA }]],
|
|
112
|
+
"GET /repos/acme/infra/issues/7/comments": () => [200, []],
|
|
113
|
+
"POST /repos/acme/infra/issues/7/comments": () => [201, { id: 11, html_url: "https://github.example/acme/infra/pull/7#c11" }],
|
|
114
|
+
});
|
|
115
|
+
const out = await runNotice({ GITHUB_API_URL: base, GITHUB_SERVER_URL: "https://github.com" });
|
|
116
|
+
expect(out).toBe("https://github.example/acme/infra/pull/7#c11");
|
|
117
|
+
const post = calls.find((c) => c.method === "POST")!;
|
|
118
|
+
expect(post.auth).toBe("Bearer job-token");
|
|
119
|
+
const body = String(post.body?.body);
|
|
120
|
+
expect(body.startsWith("<!-- chant-gate:app-apply -->\n\nThe `app-apply` apply for " + SHA + " stopped at gate `apply`")).toBe(true);
|
|
121
|
+
expect(body).toContain("```\nchant approve app-apply apply --approver <you>\n```");
|
|
122
|
+
expect(body).toContain("`_gates/app-apply.jsonl` on the `chant/lifecycle` branch");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("on GitHub it edits its own comment on a later gated push", async () => {
|
|
126
|
+
const { base, calls } = await fakeForge({
|
|
127
|
+
[`GET /repos/acme/infra/commits/${SHA}/pulls`]: () => [200, [{ number: 7, merged_at: "x", merge_commit_sha: SHA }]],
|
|
128
|
+
"GET /repos/acme/infra/issues/7/comments": () => [200, [{ id: 5, body: "<!-- chant-gate:app-apply -->\n\nolder", html_url: "u5" }]],
|
|
129
|
+
"PATCH /repos/acme/infra/issues/comments/5": () => [200, { id: 5, html_url: "u5" }],
|
|
130
|
+
});
|
|
131
|
+
expect(await runNotice({ GITHUB_API_URL: base })).toBe("u5");
|
|
132
|
+
expect(calls.map((c) => c.method)).toEqual(["GET", "GET", "PATCH"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("on Forgejo a commit with no pull request gets a tracking issue", async () => {
|
|
136
|
+
const { base, calls } = await fakeForge({
|
|
137
|
+
"GET /api/v1/repos/acme/infra/issues": () => [200, []],
|
|
138
|
+
"POST /api/v1/repos/acme/infra/issues": () => [201, { number: 3, html_url: "https://forgejo.example/acme/infra/issues/3" }],
|
|
139
|
+
});
|
|
140
|
+
const out = await runNotice({ FORGEJO_ACTIONS: "true", GITHUB_API_URL: `${base}/api/v1`, GITHUB_SERVER_URL: base });
|
|
141
|
+
expect(out).toBe("https://forgejo.example/acme/infra/issues/3");
|
|
142
|
+
// Forgejo's own lookup, answered 404 here: no pull request merged this commit.
|
|
143
|
+
expect(calls[0]).toMatchObject({ method: "GET", url: `/api/v1/repos/acme/infra/commits/${SHA}/pull` });
|
|
144
|
+
const post = calls.find((c) => c.method === "POST")!;
|
|
145
|
+
expect(post.auth).toBe("token job-token");
|
|
146
|
+
expect(post.body?.title).toBe("app-apply is waiting on gate apply");
|
|
147
|
+
expect(String(post.body?.body).startsWith("<!-- chant-gate:app-apply -->")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* is a single edit reflected in every generated file.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
14
15
|
import { describe, test, expect } from "vitest";
|
|
15
16
|
import { parseYAML } from "@intentius/chant/yaml";
|
|
16
17
|
import { generateGithubOpPipeline } from "./generate-op-pipeline";
|
|
@@ -492,33 +493,38 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
|
|
|
492
493
|
const notice = pushDoc().jobs!["app-apply-gate-notice"];
|
|
493
494
|
expect(notice.needs).toBe("app-apply");
|
|
494
495
|
expect(notice.if).toBe("needs.app-apply.outputs.gated == 'true'");
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
expect(notice.container).
|
|
496
|
+
// The Op's own image, which has node and npm, rather than gh on a hosted
|
|
497
|
+
// runner image (#3773).
|
|
498
|
+
expect(notice.container).toBe("node:22");
|
|
498
499
|
const script = notice.steps[0].run ?? "";
|
|
499
|
-
expect(script).toContain('
|
|
500
|
-
expect(script).toContain('marker="<!-- chant-gate
|
|
501
|
-
expect(script).toContain("
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
500
|
+
expect(script).toContain('import { forgeClient, forgeTargetFromEnv } from "@intentius/chant/pr-forge";');
|
|
501
|
+
expect(script).toContain('const marker = "<!-- chant-gate:" + op + " -->";');
|
|
502
|
+
expect(script).toContain("forge.pullRequestFor(target.sha)");
|
|
503
|
+
expect(script).toContain("forge.upsertNote(pr, marker, body)");
|
|
504
|
+
expect(script).toContain('forge.upsertIssue(marker, op + " is waiting on gate " + gate, body)');
|
|
505
|
+
expect(script).toContain("node --import tsx notice.mjs");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// #3773 (and #3780 for its `gh issue create`): no `gh` call is left to
|
|
509
|
+
// default to github.com on a GHES or Forgejo host. The forge, the REST base
|
|
510
|
+
// and the host come from the job's environment through pr-forge.
|
|
511
|
+
test("the notice job runs no gh at all", () => {
|
|
512
|
+
const notice = pushDoc().jobs!["app-apply-gate-notice"];
|
|
513
|
+
expect(notice.steps[0].run ?? "").not.toMatch(/(^|\s)gh\s/m);
|
|
514
|
+
const env = notice.steps[0].env ?? {};
|
|
515
|
+
expect(env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
516
|
+
expect(env).not.toHaveProperty("GH_REPO");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("the notice job installs the chant release this lexicon was generated with", () => {
|
|
520
|
+
const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string };
|
|
513
521
|
const script = pushDoc().jobs!["app-apply-gate-notice"].steps[0].run ?? "";
|
|
514
|
-
expect(script).toContain(
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
expect(
|
|
520
|
-
// No bare-path call survives.
|
|
521
|
-
expect(script).not.toMatch(/gh api[^\n]*"repos\//);
|
|
522
|
+
expect(script).toContain(`npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("the notice job runs in options.image when one is given", () => {
|
|
526
|
+
const doc = parseFile(generateGithubOpPipeline([pushSpec], { image: "registry.example/node:22" }).files[0].yaml);
|
|
527
|
+
expect(doc.jobs!["app-apply-gate-notice"].container).toBe("registry.example/node:22");
|
|
522
528
|
});
|
|
523
529
|
|
|
524
530
|
test("the notice job's permissions are its two posting paths and the lookup", () => {
|
|
@@ -544,7 +550,7 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
|
|
|
544
550
|
|
|
545
551
|
/**
|
|
546
552
|
* chant #2299 — every Op job carries `container:` (this generator sets one
|
|
547
|
-
* unconditionally, default `node:22
|
|
553
|
+
* unconditionally, default `node:22`), and a container job's default
|
|
548
554
|
* shell on GitHub Actions is `sh`, not bash. `sh` rejects `set -o pipefail`
|
|
549
555
|
* outright (`Illegal option -o pipefail`) and fails the step before `chant`
|
|
550
556
|
* is ever reached, which is exactly what happened on a real run (choudoufu
|
|
@@ -555,7 +561,7 @@ describe("generateGithubOpPipeline: the gated apply on push (#2243)", () => {
|
|
|
555
561
|
test("the run step needs no shell override — its script is POSIX sh, not bash (#2299)", () => {
|
|
556
562
|
const doc = pushDoc();
|
|
557
563
|
const job = doc.jobs!["app-apply"];
|
|
558
|
-
expect(job.container).toBe("node:22
|
|
564
|
+
expect(job.container).toBe("node:22");
|
|
559
565
|
const step = job.steps.find((s) => s.id === "chant-run");
|
|
560
566
|
expect(step?.shell).toBeUndefined();
|
|
561
567
|
expect(step?.run).not.toContain("pipefail");
|
|
@@ -620,7 +626,7 @@ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2
|
|
|
620
626
|
"jobs:",
|
|
621
627
|
" actions-audit:",
|
|
622
628
|
" runs-on: ubuntu-latest",
|
|
623
|
-
" container: node:22
|
|
629
|
+
" container: node:22",
|
|
624
630
|
" steps:",
|
|
625
631
|
" - uses: actions/checkout@v7",
|
|
626
632
|
" - run: chant run actions-audit",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
* that job either, same as `environment:` above does not).
|
|
71
71
|
*/
|
|
72
72
|
|
|
73
|
+
import { readFileSync } from "node:fs";
|
|
73
74
|
import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
|
|
74
75
|
import { memberPathFilter, resolveOpTrigger } from "@intentius/chant/lexicon";
|
|
75
76
|
import type {
|
|
@@ -130,11 +131,10 @@ export interface GithubOpPipelineDoc {
|
|
|
130
131
|
jobsDoc: Record<string, unknown>;
|
|
131
132
|
/**
|
|
132
133
|
* The gated-apply notice job (#2243), when this Op's trigger is `push`.
|
|
133
|
-
* Kept out of {@link jobsDoc} so a dialect
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* into `jobs:` for the forges that can. GitLab reaches the same outcome
|
|
134
|
+
* Kept out of {@link jobsDoc} so a dialect transforms it on its own terms;
|
|
135
|
+
* the forgejo generator carries it across since #3773, when the job moved
|
|
136
|
+
* off `gh` onto `@intentius/chant/pr-forge`. {@link emitOpPipelineYAML}
|
|
137
|
+
* merges it into `jobs:`. GitLab reaches the same outcome
|
|
138
138
|
* without this job at all: its push job writes the pending block to an
|
|
139
139
|
* artifact instead (#2256).
|
|
140
140
|
*/
|
|
@@ -153,7 +153,7 @@ function toJobName(opName: string): string {
|
|
|
153
153
|
return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
const DEFAULT_IMAGE = "node:22
|
|
156
|
+
const DEFAULT_IMAGE = "node:22";
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
159
|
* Default branch assumed for a `push` trigger with no `branches` override
|
|
@@ -287,18 +287,14 @@ const GATE_OUTPUT_SCRIPT =
|
|
|
287
287
|
*
|
|
288
288
|
* The `node -e` line and the job's own `outputs:` mapping exist for exactly
|
|
289
289
|
* one reader: the gate-notice job beside it, which is `needs:`-only readable
|
|
290
|
-
* through `steps.<id>.outputs`/`needs.<job>.outputs`.
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* keeping the line would be a wired-up mechanism with no reader, which is the
|
|
294
|
-
* exact thing #2294 is about. The tee'd invocation survives either way — it's
|
|
295
|
-
* what puts the run's own JSON record in the log, gate-notice job or not.
|
|
290
|
+
* through `steps.<id>.outputs`/`needs.<job>.outputs`. Forgejo dropped both
|
|
291
|
+
* while it had no notice job (#2294) and carries them again since #3773. The
|
|
292
|
+
* tee'd invocation is what puts the run's own JSON record in the log.
|
|
296
293
|
*/
|
|
297
294
|
function gatedRunStep(
|
|
298
295
|
id: string,
|
|
299
296
|
op: string,
|
|
300
297
|
invocation: string,
|
|
301
|
-
emitOutputs: boolean,
|
|
302
298
|
env: Record<string, string>,
|
|
303
299
|
): Record<string, unknown> {
|
|
304
300
|
const lines = [
|
|
@@ -309,70 +305,86 @@ function gatedRunStep(
|
|
|
309
305
|
'code=$(cat "$status")',
|
|
310
306
|
'[ "$code" -eq 0 ] || exit "$code"',
|
|
311
307
|
];
|
|
312
|
-
|
|
308
|
+
lines.push(`node -e '${GATE_OUTPUT_SCRIPT}' "$json"`);
|
|
313
309
|
return { id, run: lines.join("\n"), env };
|
|
314
310
|
}
|
|
315
311
|
|
|
316
312
|
/**
|
|
317
|
-
* The
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
313
|
+
* The chant release the notice job installs: this lexicon's own version.
|
|
314
|
+
* The github lexicon and `@intentius/chant` release in lockstep, so the pr-forge
|
|
315
|
+
* a generated workflow runs is the one this generator was built against, and
|
|
316
|
+
* it changes only when the workflow is regenerated, the same as a pinned
|
|
317
|
+
* action ref.
|
|
321
318
|
*/
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
319
|
+
function chantVersion(): string {
|
|
320
|
+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version?: unknown };
|
|
321
|
+
if (typeof pkg.version !== "string" || pkg.version === "") {
|
|
322
|
+
throw new Error("the github lexicon's package.json has no version to pin the gate-notice job's chant to");
|
|
323
|
+
}
|
|
324
|
+
return pkg.version;
|
|
325
|
+
}
|
|
326
326
|
|
|
327
327
|
/**
|
|
328
|
-
* What the notice job
|
|
329
|
-
* `comment` mode
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* direct push to the branch, a merge whose commit the API does not associate —
|
|
340
|
-
* the notice becomes an issue instead, which is the `issue` finding mode's own
|
|
341
|
-
* recipe and the reason this job carries `issues: write`.
|
|
328
|
+
* What the notice job runs under node (#3773): the sticky-comment recipe
|
|
329
|
+
* `reconcilePr`'s `comment` mode uses, through the same
|
|
330
|
+
* `@intentius/chant/pr-forge` clients, so it posts on GitHub, GitHub
|
|
331
|
+
* Enterprise Server and Forgejo alike. `forgeTargetFromEnv` reads the forge,
|
|
332
|
+
* the REST base, the repository, the commit and the token off the job's
|
|
333
|
+
* environment (`CHANT_FORGEJO_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`),
|
|
334
|
+
* and `pullRequestFor` is the commit-to-pull-request lookup each forge spells
|
|
335
|
+
* its own way (`commits/{sha}/pulls` on GitHub, `commits/{sha}/pull` on
|
|
336
|
+
* Forgejo). A commit with no pull request, a direct push, gets a tracking
|
|
337
|
+
* issue keyed on the same marker, so a second gated push edits it rather
|
|
338
|
+
* than opening another.
|
|
342
339
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
340
|
+
* Plain JavaScript in double-quoted strings, so the markdown backticks need
|
|
341
|
+
* no escaping and nothing in it reads as a `${{ }}` expression.
|
|
342
|
+
*/
|
|
343
|
+
const NOTICE_SCRIPT = [
|
|
344
|
+
'import { forgeClient, forgeTargetFromEnv } from "@intentius/chant/pr-forge";',
|
|
345
|
+
"const { CHANT_OP: op, CHANT_GATE: gate, CHANT_APPROVE: approve } = process.env;",
|
|
346
|
+
"const target = forgeTargetFromEnv(process.env);",
|
|
347
|
+
"const forge = forgeClient(target);",
|
|
348
|
+
'const marker = "<!-- chant-gate:" + op + " -->";',
|
|
349
|
+
'const fence = "```";',
|
|
350
|
+
"const body = [",
|
|
351
|
+
" marker,",
|
|
352
|
+
' "",',
|
|
353
|
+
' "The `" + op + "` apply for " + target.sha + " stopped at gate `" + gate + "` and is waiting for an approval. Nothing was applied.",',
|
|
354
|
+
' "",',
|
|
355
|
+
" fence,",
|
|
356
|
+
' approve + " --approver <you>",',
|
|
357
|
+
" fence,",
|
|
358
|
+
' "",',
|
|
359
|
+
' "The pending fact is on `_gates/" + op + ".jsonl` on the `chant/lifecycle` branch. Approving is a commit: push it and this workflow runs again and applies.",',
|
|
360
|
+
'].join("\\n") + "\\n";',
|
|
361
|
+
"const pr = target.sha ? await forge.pullRequestFor(target.sha) : null;",
|
|
362
|
+
"const url = pr",
|
|
363
|
+
" ? await forge.upsertNote(pr, marker, body)",
|
|
364
|
+
' : await forge.upsertIssue(marker, op + " is waiting on gate " + gate, body);',
|
|
365
|
+
'console.log(url ?? (pr ? "commented on #" + pr : "kept the tracking issue"));',
|
|
366
|
+
].join("\n");
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The notice step's shell (#3773). It installs the pinned chant into the
|
|
370
|
+
* runner's temp directory and runs {@link NOTICE_SCRIPT} with tsx, which
|
|
371
|
+
* chant depends on and which is how `chant` itself runs its TypeScript.
|
|
372
|
+
* `--omit=peer` keeps chant's optional vitest peer out: about 17 packages,
|
|
373
|
+
* a few seconds. The `package.json` written first stops npm from walking up
|
|
374
|
+
* to some other project root. No checkout and no `beforeScript`: the job
|
|
375
|
+
* reads nothing out of the repository.
|
|
355
376
|
*/
|
|
356
|
-
function gateNoticeScript(): string {
|
|
377
|
+
function gateNoticeScript(version: string): string {
|
|
357
378
|
return [
|
|
358
|
-
'
|
|
359
|
-
'
|
|
360
|
-
'
|
|
361
|
-
"
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
"
|
|
367
|
-
"fi",
|
|
368
|
-
'id=$(gh api "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate ' +
|
|
369
|
-
'--jq "map(select(.body | startswith(\\"$marker\\"))) | .[0].id // empty" ' +
|
|
370
|
-
'| grep -m1 -E "^[0-9]+$" || true)',
|
|
371
|
-
'if [ -n "$id" ]; then',
|
|
372
|
-
' gh api --method PATCH "$api_base/repos/$GITHUB_REPOSITORY/issues/comments/$id" -f "body=$body" --jq .html_url',
|
|
373
|
-
"else",
|
|
374
|
-
' gh api --method POST "$api_base/repos/$GITHUB_REPOSITORY/issues/$pr/comments" -f "body=$body" --jq .html_url',
|
|
375
|
-
"fi",
|
|
379
|
+
'dir="${RUNNER_TEMP:-/tmp}/chant-gate-notice"',
|
|
380
|
+
'mkdir -p "$dir"',
|
|
381
|
+
'cd "$dir"',
|
|
382
|
+
"echo '{\"private\":true}' > package.json",
|
|
383
|
+
`npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`,
|
|
384
|
+
"cat > notice.mjs <<'NOTICE'",
|
|
385
|
+
NOTICE_SCRIPT,
|
|
386
|
+
"NOTICE",
|
|
387
|
+
"node --import tsx notice.mjs",
|
|
376
388
|
].join("\n");
|
|
377
389
|
}
|
|
378
390
|
|
|
@@ -380,34 +392,35 @@ function gateNoticeScript(): string {
|
|
|
380
392
|
* The follow-up job: `needs:` the apply, runs only when the apply reported
|
|
381
393
|
* gated, and puts the pending state somewhere other than the Actions log.
|
|
382
394
|
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
395
|
+
* It runs in the Op's own image, which carries node and npm by construction
|
|
396
|
+
* (the apply's `node -e` output step needs them too), so it runs the same on
|
|
397
|
+
* GitHub's hosted runners and on a Forgejo runner. It checks nothing out.
|
|
386
398
|
*
|
|
387
399
|
* Its `permissions:` are its own, replacing the workflow-level set for this
|
|
388
400
|
* job alone: `contents: read` for the commit-to-pull-request lookup,
|
|
389
401
|
* `pull-requests: write` for the sticky comment, `issues: write` for the
|
|
390
|
-
*
|
|
391
|
-
* branch and merges nothing.
|
|
402
|
+
* tracking issue when the push has no pull request. Nothing wider: it opens
|
|
403
|
+
* no branch and merges nothing. The Forgejo dialect drops them, as it drops
|
|
404
|
+
* every scope but `id-token`.
|
|
392
405
|
*/
|
|
393
|
-
function gateNoticeJob(applyJobName: string): Record<string, unknown> {
|
|
406
|
+
function gateNoticeJob(applyJobName: string, image: string): Record<string, unknown> {
|
|
394
407
|
const output = (name: string) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
|
|
395
408
|
return {
|
|
396
409
|
needs: applyJobName,
|
|
397
410
|
if: `needs.${applyJobName}.outputs.gated == 'true'`,
|
|
398
411
|
"runs-on": "ubuntu-latest",
|
|
412
|
+
container: image,
|
|
399
413
|
permissions: { contents: "read", issues: "write", "pull-requests": "write" },
|
|
400
414
|
steps: [
|
|
401
415
|
{
|
|
402
416
|
name: "Report the pending gate",
|
|
403
417
|
env: {
|
|
404
|
-
|
|
405
|
-
GH_REPO: "${{ github.repository }}",
|
|
418
|
+
GITHUB_TOKEN: "${{ github.token }}",
|
|
406
419
|
CHANT_OP: output("op"),
|
|
407
420
|
CHANT_GATE: output("gate"),
|
|
408
421
|
CHANT_APPROVE: output("approve"),
|
|
409
422
|
},
|
|
410
|
-
run: gateNoticeScript(),
|
|
423
|
+
run: gateNoticeScript(chantVersion()),
|
|
411
424
|
},
|
|
412
425
|
],
|
|
413
426
|
};
|
|
@@ -639,24 +652,6 @@ function setupStepDoc(step: OpSetupStep): Record<string, unknown> {
|
|
|
639
652
|
};
|
|
640
653
|
}
|
|
641
654
|
|
|
642
|
-
/**
|
|
643
|
-
* Internal knobs beside the public {@link GenerateGithubOpOptions} — not part
|
|
644
|
-
* of that type because they are not something a project author sets, only
|
|
645
|
-
* something a dialect on top of this builder (forgejo, #2294) needs to
|
|
646
|
-
* change about the shape this function emits for every spec alike.
|
|
647
|
-
*/
|
|
648
|
-
export interface BuildGithubOpPipelineDocsInternalOptions {
|
|
649
|
-
/**
|
|
650
|
-
* Whether a `push` job's outputs (`gated`/`op`/`gate`/`approve`) and the
|
|
651
|
-
* `node -e` step that writes them to `$GITHUB_OUTPUT` are emitted (#2294).
|
|
652
|
-
* Default true (github's own behavior, unchanged): the gate-notice job
|
|
653
|
-
* beside it reads them. The forgejo generator passes `false` — it never
|
|
654
|
-
* carries a gate-notice job (`gatedNoticeDoc` never crosses the dialect),
|
|
655
|
-
* so nothing would ever read them there.
|
|
656
|
-
*/
|
|
657
|
-
emitGatedOutputs?: boolean;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
655
|
/**
|
|
661
656
|
* Build one `GithubOpPipelineDoc` per scheduled Op: its trigger, its `setup`
|
|
662
657
|
* steps, least-privilege `permissions:` for its finding-mode plus whatever
|
|
@@ -672,9 +667,7 @@ export interface BuildGithubOpPipelineDocsInternalOptions {
|
|
|
672
667
|
export function buildGithubOpPipelineDocs(
|
|
673
668
|
ops: ScheduledOpSpec[],
|
|
674
669
|
options: GenerateGithubOpOptions = {},
|
|
675
|
-
internalOptions: BuildGithubOpPipelineDocsInternalOptions = {},
|
|
676
670
|
): { files: GithubOpPipelineFile[]; jobs: OpPipelineJob[] } {
|
|
677
|
-
const emitGatedOutputs = internalOptions.emitGatedOutputs ?? true;
|
|
678
671
|
const image = options.image ?? DEFAULT_IMAGE;
|
|
679
672
|
const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
|
|
680
673
|
const beforeScript = options.beforeScript ?? [];
|
|
@@ -715,7 +708,7 @@ export function buildGithubOpPipelineDocs(
|
|
|
715
708
|
for (const line of beforeScript) steps.push({ run: line });
|
|
716
709
|
steps.push(
|
|
717
710
|
gated
|
|
718
|
-
? gatedRunStep(RUN_STEP_ID, spec.name, invocation,
|
|
711
|
+
? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
|
|
719
712
|
: { run: invocation, env: stepEnv },
|
|
720
713
|
);
|
|
721
714
|
for (const line of extraScript) steps.push({ run: line });
|
|
@@ -747,7 +740,7 @@ export function buildGithubOpPipelineDocs(
|
|
|
747
740
|
// behind the same reviewer would hold the message back until
|
|
748
741
|
// somebody had already acted.
|
|
749
742
|
...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
|
|
750
|
-
...(gated
|
|
743
|
+
...(gated
|
|
751
744
|
? {
|
|
752
745
|
outputs: Object.fromEntries(
|
|
753
746
|
["gated", "op", "gate", "approve"].map((name) => [
|
|
@@ -760,7 +753,7 @@ export function buildGithubOpPipelineDocs(
|
|
|
760
753
|
steps,
|
|
761
754
|
},
|
|
762
755
|
},
|
|
763
|
-
...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName) } } : {}),
|
|
756
|
+
...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image) } } : {}),
|
|
764
757
|
};
|
|
765
758
|
|
|
766
759
|
files.push({ name: `${spec.name}.yml`, doc });
|
|
@@ -827,9 +820,8 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
|
|
|
827
820
|
if (doc.env && Object.keys(doc.env).length > 0) sections.push(emitYAMLEntry("env", doc.env));
|
|
828
821
|
sections.push(emitYAMLEntry("concurrency", doc.concurrency));
|
|
829
822
|
if (Object.keys(doc.permissions).length > 0) sections.push(emitYAMLEntry("permissions", doc.permissions));
|
|
830
|
-
// The gated-apply notice job rides in `jobs:` beside the Op's own job
|
|
831
|
-
// is carried separately on the doc so a dialect
|
|
832
|
-
// whose runner has no `gh` pointed at its own instance) drops it by omission.
|
|
823
|
+
// The gated-apply notice job rides in `jobs:` beside the Op's own job; it
|
|
824
|
+
// is carried separately on the doc so a dialect transforms it on its own.
|
|
833
825
|
sections.push(emitYAMLEntry("jobs", { ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }));
|
|
834
826
|
return sections.join("\n\n") + "\n";
|
|
835
827
|
}
|
|
@@ -31,6 +31,15 @@ describe("generateGithubOpWavesPipeline", () => {
|
|
|
31
31
|
expect(yaml).toContain("environment:\n name: production");
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
test("notify maps each variable it names from the secret of that name, and options.variables wins (#3761)", () => {
|
|
35
|
+
const notify = { webhook: "CHANT_WEBHOOK", key: "CHANT_WEBHOOK_KEY", slack: "CHANT_SLACK_WEBHOOK" };
|
|
36
|
+
const yaml = generateGithubOpWavesPipeline({ ...spec, notify }, { specFile: "waves.json", variables: { CHANT_SLACK_WEBHOOK: "${{ vars.SLACK }}" } }).files[0]!.yaml;
|
|
37
|
+
expect(yaml).toContain("env:\n CHANT_WEBHOOK: '${{ secrets.CHANT_WEBHOOK }}'");
|
|
38
|
+
expect(yaml).toContain("CHANT_WEBHOOK_KEY: '${{ secrets.CHANT_WEBHOOK_KEY }}'");
|
|
39
|
+
expect(yaml).toContain("CHANT_SLACK_WEBHOOK: '${{ vars.SLACK }}'");
|
|
40
|
+
expect(generateGithubOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml).not.toContain("secrets.");
|
|
41
|
+
});
|
|
42
|
+
|
|
34
43
|
test("resume renders a scheduled workflow that re-runs approved waves with actions: write (#3683)", () => {
|
|
35
44
|
const { files } = generateGithubOpWavesPipeline({ ...spec, resume: { schedule: "*/10 * * * *" } }, { specFile: "waves.json" });
|
|
36
45
|
expect(files.map((f) => f.name)).toEqual([`${spec.name}.yml`, `${spec.name}-resume.yml`]);
|
|
@@ -49,4 +58,19 @@ describe("generateGithubOpWavesPipeline", () => {
|
|
|
49
58
|
expect(files[1]!.yaml).toContain("run: chant run wave --spec waves.json --record-plans");
|
|
50
59
|
expect(files[0]!.yaml).toMatch(/permissions:\n\s+contents: write\n\s+pull-requests: read/);
|
|
51
60
|
});
|
|
61
|
+
|
|
62
|
+
test("a wave-level plan and apply, and stage hooks, render the same jobs: chant run wave runs them (#3762)", () => {
|
|
63
|
+
const { plan: _plan, ...rest } = spec;
|
|
64
|
+
const waveLevel: OpWavesSpec = {
|
|
65
|
+
...rest,
|
|
66
|
+
planWave: ["./bin/plan-wave", "{runs}", "{plans}"],
|
|
67
|
+
applyWave: ["./bin/apply-wave", "{runs}"],
|
|
68
|
+
hooks: { before: { apply: [{ command: ["./bin/check", "{runs}"], onFailure: "approve" }] } },
|
|
69
|
+
};
|
|
70
|
+
const plain = generateGithubOpWavesPipeline(spec, { specFile: "waves.json" });
|
|
71
|
+
const rendered = generateGithubOpWavesPipeline(waveLevel, { specFile: "waves.json" });
|
|
72
|
+
expect(rendered.files).toEqual(plain.files);
|
|
73
|
+
expect(rendered.jobs).toEqual(plain.jobs);
|
|
74
|
+
expect(() => generateGithubOpWavesPipeline({ ...waveLevel, hooks: { after: { apply: [{ command: ["./bin/smoke"], onFailure: "approve" }] } } }, { specFile: "waves.json" })).toThrow(/nothing left to hold/);
|
|
75
|
+
});
|
|
52
76
|
});
|
|
@@ -23,8 +23,13 @@
|
|
|
23
23
|
* `pull-requests: read`, so a wave can read the merged pull request's
|
|
24
24
|
* reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
|
|
25
25
|
* --record-plans` on each pull request to record the digests its head plans.
|
|
26
|
+
*
|
|
27
|
+
* With `notify` on the spec (#3761), the workflow maps each variable it names
|
|
28
|
+
* from the repository secret of the same name, so a wave job can post to
|
|
29
|
+
* Slack, Teams or a signed webhook. `options.variables` wins over the mapping.
|
|
26
30
|
*/
|
|
27
31
|
|
|
32
|
+
import { notifyVariables } from "@intentius/chant/notify";
|
|
28
33
|
import { opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
|
|
29
34
|
import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
|
|
30
35
|
import { actionRef } from "../action-pins";
|
|
@@ -102,10 +107,12 @@ export function buildGithubOpWavesDoc(
|
|
|
102
107
|
};
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
// #3761: the notify variables come from the secrets of the same name.
|
|
111
|
+
const env = { ...Object.fromEntries(notifyVariables(spec.notify).map((n) => [n, `\${{ secrets.${n} }}`])), ...(options.variables ?? {}) };
|
|
105
112
|
const doc: GithubOpPipelineDoc = {
|
|
106
113
|
name: spec.name,
|
|
107
114
|
on: { push: { branches: spec.branches?.length ? spec.branches : ["main"] }, workflow_dispatch: {} },
|
|
108
|
-
...(
|
|
115
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
109
116
|
concurrency: { group: spec.name, "cancel-in-progress": false },
|
|
110
117
|
permissions: { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) },
|
|
111
118
|
jobsDoc,
|
|
@@ -338,7 +338,7 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
|
|
|
338
338
|
|
|
339
339
|
const promote = jobs["promote-prod"];
|
|
340
340
|
expect(promote.needs).toEqual(["api", "shared-alb", "worker"]);
|
|
341
|
-
expect(promote.container).toBe("node:22
|
|
341
|
+
expect(promote.container).toBe("node:22");
|
|
342
342
|
const downloads = promote.steps.filter((s) => s.uses === "actions/download-artifact@v4");
|
|
343
343
|
expect(downloads.map((s) => s.with)).toEqual([
|
|
344
344
|
{ name: "api-archive", path: "dist/images" },
|
|
@@ -282,7 +282,7 @@ function toJobName(componentName: string): string {
|
|
|
282
282
|
return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
const DEFAULT_IMAGE = "node:22
|
|
285
|
+
const DEFAULT_IMAGE = "node:22";
|
|
286
286
|
|
|
287
287
|
/** The file a producer dumps its resolved outputs to, and a dependent seeds from — one per component, matching gitlab's naming so both generators produce interchangeable artifacts. */
|
|
288
288
|
function outputsFile(name: string): string {
|
|
@@ -20,7 +20,7 @@ import type { SerializerResult } from "@intentius/chant/serializer";
|
|
|
20
20
|
* loosely-typed instance directly.
|
|
21
21
|
*/
|
|
22
22
|
interface StepLike {
|
|
23
|
-
props: { name?: string; run?: string };
|
|
23
|
+
props: { name?: string; run?: string; env?: Record<string, string> };
|
|
24
24
|
}
|
|
25
25
|
interface JobLike {
|
|
26
26
|
props: {
|
|
@@ -125,6 +125,19 @@ describe("PrPlanReport composite (#1983)", () => {
|
|
|
125
125
|
expect(run).not.toMatch(/gh api[^\n]*"repos\//);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
// #2352: `gh` sends GH_TOKEN only to github.com and ghe.com hosts. Checked
|
|
129
|
+
// against a real Forgejo 16.0.5 instance with no `gh auth login`: GH_TOKEN
|
|
130
|
+
// alone posted with no Authorization header and got 401; with
|
|
131
|
+
// GH_ENTERPRISE_TOKEN beside it the POST got 201 and a re-run PATCHed.
|
|
132
|
+
test("the post step carries the token as GH_ENTERPRISE_TOKEN too, which a non-github.com host reads (#2352)", () => {
|
|
133
|
+
const { job } = PrPlanReport({ environment: "prod" });
|
|
134
|
+
const postStep = steps(job).find((s) => s.props.name === "Post or update PR comment")!;
|
|
135
|
+
expect(postStep.props.env).toEqual({
|
|
136
|
+
GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
|
|
137
|
+
GH_ENTERPRISE_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
128
141
|
test("the emitted workflow passes the github lexicon's own lint — no errors, pinned actions included", () => {
|
|
129
142
|
const { job } = PrPlanReport({ environment: "prod", before: ["aws sts get-caller-identity"] });
|
|
130
143
|
const workflow = new Workflow({
|
|
@@ -73,6 +73,16 @@ export interface PrPlanReportProps {
|
|
|
73
73
|
* does not serve `/api/v3` and answered 404 for GET and POST alike. On
|
|
74
74
|
* github.com and GHES the resolved URL is byte-identical to `gh`'s own
|
|
75
75
|
* guess, so this changes nothing there.
|
|
76
|
+
*
|
|
77
|
+
* The step that runs it sets the token twice, as `GH_TOKEN` and as
|
|
78
|
+
* `GH_ENTERPRISE_TOKEN` (chant #2352). `gh` sends `GH_TOKEN` only to
|
|
79
|
+
* github.com and `ghe.com` hosts and reads `GH_ENTERPRISE_TOKEN` for every
|
|
80
|
+
* other host, Forgejo and GitHub Enterprise Server included. With `GH_TOKEN`
|
|
81
|
+
* alone, this script's POST to a real Forgejo 16.0.5 instance went out with
|
|
82
|
+
* no `Authorization` header and answered 401 `token is required`; with both
|
|
83
|
+
* set it answered 201, and a second run PATCHed the same comment. On
|
|
84
|
+
* github.com `GH_TOKEN` still wins, so nothing changes there. The same fix
|
|
85
|
+
* `ghCredentialEnv` made for `reconcilePr` at runtime (#2333).
|
|
76
86
|
*/
|
|
77
87
|
const stickyCommentScript = [
|
|
78
88
|
'api_base="${GITHUB_API_URL%/}"',
|
|
@@ -151,7 +161,10 @@ export const PrPlanReport = Composite((props: PrPlanReportProps) => {
|
|
|
151
161
|
? [
|
|
152
162
|
new Step({
|
|
153
163
|
name: "Post or update PR comment",
|
|
154
|
-
env: {
|
|
164
|
+
env: {
|
|
165
|
+
GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
|
|
166
|
+
GH_ENTERPRISE_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
|
|
167
|
+
},
|
|
155
168
|
run: stickyCommentScript,
|
|
156
169
|
}),
|
|
157
170
|
]
|