@intentius/chant 0.107.1 → 0.108.1
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/cli/handlers/generate.d.ts +17 -0
- package/dist/cli/handlers/generate.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/json-schema-to-ts.d.ts +61 -0
- package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/project-codegen.d.ts +145 -0
- package/dist/project-codegen.d.ts.map +1 -0
- package/dist/workspace/ci-green-cli.d.ts +17 -0
- package/dist/workspace/ci-green-cli.d.ts.map +1 -0
- package/dist/workspace/ci-green-forge.d.ts +54 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -0
- package/dist/workspace/ci-green-workflow.d.ts +107 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-green.d.ts +191 -0
- package/dist/workspace/ci-green.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +31 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +102 -0
- package/dist/workspace/ls.d.ts +15 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/generate.ts +90 -0
- package/src/cli/main.ts +53 -0
- package/src/cli/plugins.ts +16 -2
- package/src/cli/registry.ts +12 -0
- package/src/codegen/json-schema-to-ts.test.ts +256 -0
- package/src/codegen/json-schema-to-ts.ts +373 -0
- package/src/config.ts +16 -0
- package/src/lexicon.ts +20 -0
- package/src/project-codegen.test.ts +208 -0
- package/src/project-codegen.ts +296 -0
- package/src/workspace/ci-green-cli.ts +118 -0
- package/src/workspace/ci-green-forge.test.ts +66 -0
- package/src/workspace/ci-green-forge.ts +111 -0
- package/src/workspace/ci-green-workflow.test.ts +288 -0
- package/src/workspace/ci-green-workflow.ts +252 -0
- package/src/workspace/ci-green.test.ts +401 -0
- package/src/workspace/ci-green.ts +424 -0
- package/src/workspace/ci-last-green.schema.json +158 -0
- package/src/workspace/declaration.schema.json +102 -0
- package/src/workspace/declaration.ts +64 -0
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +22 -0
- package/src/workspace/read-contract.test.ts +2 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +3 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3573 — the root CI file that runs `chant ci tick`: which workflows trigger
|
|
3
|
+
* it, what it says, and `chant ci workflow` writing and recording it under
|
|
4
|
+
* the generated-file rules (ws-042, #2524 D14).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { afterAll, describe, expect, test, vi } from "vitest";
|
|
10
|
+
import { GENERATED_MARKER } from "../discovery/files";
|
|
11
|
+
import type { CommandContext } from "../cli/registry";
|
|
12
|
+
import { parseYAMLDocument } from "../yaml";
|
|
13
|
+
import { runCiWorkflow } from "./ci-green-cli";
|
|
14
|
+
import { CI_GREEN_WORKFLOW_FILE, jobCheckRuns, renderCiGreenWorkflow, workflowsHolding } from "./ci-green-workflow";
|
|
15
|
+
import { parseDeclaration } from "./declaration";
|
|
16
|
+
import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
17
|
+
|
|
18
|
+
afterAll(cleanScratch);
|
|
19
|
+
|
|
20
|
+
/** A parsed workflow, read by key path in the assertions. */
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
type Yaml = Record<string, any>;
|
|
23
|
+
|
|
24
|
+
const GREEN = {
|
|
25
|
+
branch: "main",
|
|
26
|
+
phases: { lint: ["lint"], test: ["test", "e2e (*)"], macos: { runs: ["macos (test)"], skipped: "pass" }, docs: ["docs"] },
|
|
27
|
+
require: ["lint", "test", "macos"],
|
|
28
|
+
};
|
|
29
|
+
const green = parseDeclaration(JSON.stringify({ name: "w", schema: 1, members: [], ci: { green: GREEN } }), "chant.workspace.json").ci!.green!;
|
|
30
|
+
|
|
31
|
+
const CI = `name: ci
|
|
32
|
+
on: push
|
|
33
|
+
jobs:
|
|
34
|
+
lint:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
test:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
e2e:
|
|
39
|
+
strategy:
|
|
40
|
+
matrix:
|
|
41
|
+
browser: [chromium, webkit]
|
|
42
|
+
`;
|
|
43
|
+
const MACOS = `on: push
|
|
44
|
+
jobs:
|
|
45
|
+
mac:
|
|
46
|
+
name: macos (\${{ matrix.target }})
|
|
47
|
+
strategy:
|
|
48
|
+
matrix:
|
|
49
|
+
target: [test, webkit]
|
|
50
|
+
`;
|
|
51
|
+
const DOCS = `name: docs
|
|
52
|
+
jobs:
|
|
53
|
+
docs:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
describe("the workflows that hold required check runs (#3573)", () => {
|
|
58
|
+
test("a job reports its name or id, with matrix values or a called workflow's job after it", () => {
|
|
59
|
+
expect(jobCheckRuns(parseYAMLDocument(CI))).toEqual(["lint", "test", "e2e", "e2e (*)"]);
|
|
60
|
+
expect(jobCheckRuns(parseYAMLDocument(MACOS))).toEqual(["macos (*)"]);
|
|
61
|
+
expect(jobCheckRuns({ jobs: { deploy: { uses: "./.github/workflows/deploy.yml" } } })).toEqual(["deploy", "deploy / *"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("keeps the workflows whose jobs meet a required phase's pattern, by name or by path", () => {
|
|
65
|
+
const scan = workflowsHolding(green, [
|
|
66
|
+
{ path: ".github/workflows/ci.yml", text: CI },
|
|
67
|
+
{ path: ".github/workflows/macos.yml", text: MACOS },
|
|
68
|
+
{ path: ".github/workflows/docs.yml", text: DOCS },
|
|
69
|
+
{ path: ".github/workflows/broken.yml", text: "jobs: [" },
|
|
70
|
+
]);
|
|
71
|
+
// docs is a declared phase but not a required one, so its workflow does not trigger a tick.
|
|
72
|
+
expect(scan).toEqual({ workflows: [".github/workflows/macos.yml", "ci"], unmatched: [], unreadable: [".github/workflows/broken.yml"] });
|
|
73
|
+
expect(workflowsHolding(green, [{ path: ".github/workflows/ci.yml", text: CI }]).unmatched).toEqual([{ phase: "macos", pattern: "macos (test)" }]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("runs the tick on completion of each, on a 15-minute cron, one at a time, with the rights it needs", () => {
|
|
77
|
+
const text = renderCiGreenWorkflow({ green, workflows: ["ci", "macos"], chantVersion: "0.108.0", workspaceRoot: "." });
|
|
78
|
+
const doc = parseYAMLDocument(text) as Yaml;
|
|
79
|
+
expect(doc.name).toBe("chant-ci-green");
|
|
80
|
+
expect(doc.on.workflow_run).toEqual({ workflows: ["ci", "macos"], types: ["completed"], branches: ["main"] });
|
|
81
|
+
expect(doc.on.schedule).toEqual([{ cron: "*/15 * * * *" }]);
|
|
82
|
+
expect(doc.concurrency).toEqual({ group: "chant-ci-green", "cancel-in-progress": false });
|
|
83
|
+
expect(doc.permissions).toEqual({ contents: "write", checks: "read" });
|
|
84
|
+
const steps = doc.jobs.tick.steps;
|
|
85
|
+
expect(steps[0]).toEqual({ uses: "actions/checkout@v6", with: { ref: "main", "fetch-depth": 0 } });
|
|
86
|
+
expect(steps[2].run).toBe("npx --yes @intentius/chant@0.108.0 ci tick");
|
|
87
|
+
expect(steps[2]["working-directory"]).toBeUndefined();
|
|
88
|
+
expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
89
|
+
const nested = parseYAMLDocument(renderCiGreenWorkflow({ green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: "infra" })) as Yaml;
|
|
90
|
+
expect(nested.jobs.tick.steps[2]["working-directory"]).toBe("infra");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("--chant replaces the npx pin, and --install adds a cached install step before the tick", () => {
|
|
94
|
+
const own = parseYAMLDocument(
|
|
95
|
+
renderCiGreenWorkflow({ green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: ".", chant: "npx tsx packages/core/src/cli/main.ts", install: "npm install --ignore-scripts", npmLock: "package-lock.json" }),
|
|
96
|
+
) as Yaml;
|
|
97
|
+
const steps = own.jobs.tick.steps;
|
|
98
|
+
expect(steps[1].with).toEqual({ "node-version": "24", cache: "npm" });
|
|
99
|
+
expect(steps[2]).toEqual({ name: "Install what the tick runs", run: "npm install --ignore-scripts" });
|
|
100
|
+
expect(steps[3].run).toBe("npx tsx packages/core/src/cli/main.ts ci tick");
|
|
101
|
+
|
|
102
|
+
// In a nested workspace root both steps run there, and the cache keys on that root's lockfile.
|
|
103
|
+
const nested = parseYAMLDocument(
|
|
104
|
+
renderCiGreenWorkflow({ green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: "infra", chant: "npx chant", install: "npm ci", npmLock: "infra/package-lock.json" }),
|
|
105
|
+
) as Yaml;
|
|
106
|
+
expect(nested.jobs.tick.steps[1].with).toEqual({ "node-version": "24", cache: "npm", "cache-dependency-path": "infra/package-lock.json" });
|
|
107
|
+
expect(nested.jobs.tick.steps[2]["working-directory"]).toBe("infra");
|
|
108
|
+
expect(nested.jobs.tick.steps[3]["working-directory"]).toBe("infra");
|
|
109
|
+
|
|
110
|
+
// A command YAML would misread as a mapping or a comment stays one string.
|
|
111
|
+
const odd = parseYAMLDocument(renderCiGreenWorkflow({ green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: ".", chant: "env A=b: c # x chant" })) as Yaml;
|
|
112
|
+
expect(odd.jobs.tick.steps).toHaveLength(3);
|
|
113
|
+
expect(odd.jobs.tick.steps[1].with).toEqual({ "node-version": "24" });
|
|
114
|
+
expect(odd.jobs.tick.steps[2].run).toBe("env A=b: c # x chant ci tick");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("the token the tick pushes its tags with (#3573)", () => {
|
|
119
|
+
const base = { green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: "." };
|
|
120
|
+
|
|
121
|
+
test("without one, checkout persists the Actions token and the tick reads check runs with it", () => {
|
|
122
|
+
const steps = (parseYAMLDocument(renderCiGreenWorkflow(base)) as Yaml).jobs.tick.steps;
|
|
123
|
+
expect(steps[0].with.token).toBeUndefined();
|
|
124
|
+
expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("--token-secret checks out with the secret, so the push uses it, and nothing else changes", () => {
|
|
128
|
+
const plain = renderCiGreenWorkflow(base);
|
|
129
|
+
const text = renderCiGreenWorkflow({ ...base, push: { secret: "CI_GREEN_TOKEN" } });
|
|
130
|
+
const steps = (parseYAMLDocument(text) as Yaml).jobs.tick.steps;
|
|
131
|
+
expect(steps[0]).toEqual({ uses: "actions/checkout@v6", with: { ref: "main", "fetch-depth": 0, token: "${{ secrets.CI_GREEN_TOKEN }}" } });
|
|
132
|
+
// Check runs are still read with the Actions token: a fine-grained token can't call the Checks API.
|
|
133
|
+
expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
134
|
+
expect((parseYAMLDocument(text) as Yaml).permissions).toEqual({ contents: "write", checks: "read" });
|
|
135
|
+
expect(text.replace(" token: ${{ secrets.CI_GREEN_TOKEN }}\n", "")).toBe(plain);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("--app-id-var and --app-key-secret mint an App token before the checkout and check out with it", () => {
|
|
139
|
+
const steps = (parseYAMLDocument(renderCiGreenWorkflow({ ...base, push: { appIdVar: "CI_GREEN_APP_ID", appKeySecret: "CI_GREEN_APP_KEY" } })) as Yaml).jobs.tick.steps;
|
|
140
|
+
expect(steps[0]).toMatchObject({
|
|
141
|
+
id: "app-token",
|
|
142
|
+
uses: "actions/create-github-app-token@v2",
|
|
143
|
+
with: { "app-id": "${{ vars.CI_GREEN_APP_ID }}", "private-key": "${{ secrets.CI_GREEN_APP_KEY }}" },
|
|
144
|
+
});
|
|
145
|
+
expect(steps[1].with.token).toBe("${{ steps.app-token.outputs.token }}");
|
|
146
|
+
expect(steps[3].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("chant ci workflow (#3573)", () => {
|
|
151
|
+
test("writes the file with its header, records it on the member it ran in, and writes the same bytes twice", async () => {
|
|
152
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
153
|
+
git(root, "init", "-q");
|
|
154
|
+
writeFiles(root, {
|
|
155
|
+
"chant.workspace.json": declaration([{ name: "root", dir: ".", kind: "other", because: "the repository" }], { ci: { green: GREEN } }),
|
|
156
|
+
".github/workflows/ci.yml": CI,
|
|
157
|
+
".github/workflows/macos.yml": MACOS,
|
|
158
|
+
});
|
|
159
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
160
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
161
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
162
|
+
try {
|
|
163
|
+
const ctx = { args: { ciWorkflows: ["release"] } } as unknown as CommandContext;
|
|
164
|
+
expect(await runCiWorkflow(ctx)).toBe(0);
|
|
165
|
+
const file = join(root, CI_GREEN_WORKFLOW_FILE);
|
|
166
|
+
const first = readFileSync(file, "utf-8");
|
|
167
|
+
expect(first.split("\n")[0]).toBe(`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --workflow release (in the member's directory)`);
|
|
168
|
+
expect((parseYAMLDocument(first) as Yaml).on.workflow_run.workflows).toEqual([".github/workflows/macos.yml", "ci", "release"]);
|
|
169
|
+
const record = JSON.parse(readFileSync(join(root, ".chant", "generated.json"), "utf-8"));
|
|
170
|
+
expect(record.files).toEqual([{ path: CI_GREEN_WORKFLOW_FILE, command: "chant ci workflow --workflow release" }]);
|
|
171
|
+
// The scan skips the file it writes, so regenerating is byte-identical.
|
|
172
|
+
expect(await runCiWorkflow(ctx)).toBe(0);
|
|
173
|
+
expect(readFileSync(file, "utf-8")).toBe(first);
|
|
174
|
+
} finally {
|
|
175
|
+
cwd.mockRestore();
|
|
176
|
+
log.mockRestore();
|
|
177
|
+
error.mockRestore();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("--chant and --install go into the file and its header, and regenerate byte-identically", async () => {
|
|
182
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
183
|
+
git(root, "init", "-q");
|
|
184
|
+
writeFiles(root, {
|
|
185
|
+
"chant.workspace.json": declaration([], { ci: { green: GREEN } }),
|
|
186
|
+
"package-lock.json": "{}",
|
|
187
|
+
".github/workflows/ci.yml": CI,
|
|
188
|
+
".github/workflows/macos.yml": MACOS,
|
|
189
|
+
});
|
|
190
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
191
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
192
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
193
|
+
try {
|
|
194
|
+
const ctx = { args: { ciChant: "npx tsx packages/core/src/cli/main.ts", ciInstall: "npm install --ignore-scripts" } } as unknown as CommandContext;
|
|
195
|
+
expect(await runCiWorkflow(ctx)).toBe(0);
|
|
196
|
+
const file = join(root, CI_GREEN_WORKFLOW_FILE);
|
|
197
|
+
const first = readFileSync(file, "utf-8");
|
|
198
|
+
expect(first.split("\n")[0]).toBe(
|
|
199
|
+
`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --chant 'npx tsx packages/core/src/cli/main.ts' --install 'npm install --ignore-scripts' (in the workspace root)`,
|
|
200
|
+
);
|
|
201
|
+
const steps = (parseYAMLDocument(first) as Yaml).jobs.tick.steps;
|
|
202
|
+
expect(steps[1].with.cache).toBe("npm");
|
|
203
|
+
expect(steps[2].run).toBe("npm install --ignore-scripts");
|
|
204
|
+
expect(steps[3].run).toBe("npx tsx packages/core/src/cli/main.ts ci tick");
|
|
205
|
+
expect(await runCiWorkflow(ctx)).toBe(0);
|
|
206
|
+
expect(readFileSync(file, "utf-8")).toBe(first);
|
|
207
|
+
} finally {
|
|
208
|
+
cwd.mockRestore();
|
|
209
|
+
log.mockRestore();
|
|
210
|
+
error.mockRestore();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("--token-secret goes into the file and its header, and without it the bytes are what they were", async () => {
|
|
215
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
216
|
+
git(root, "init", "-q");
|
|
217
|
+
writeFiles(root, { "chant.workspace.json": declaration([], { ci: { green: GREEN } }), ".github/workflows/ci.yml": CI, ".github/workflows/macos.yml": MACOS });
|
|
218
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
219
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
220
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
221
|
+
try {
|
|
222
|
+
const file = join(root, CI_GREEN_WORKFLOW_FILE);
|
|
223
|
+
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
|
|
224
|
+
const plain = readFileSync(file, "utf-8");
|
|
225
|
+
expect(plain).not.toMatch(/token:|secrets\./);
|
|
226
|
+
|
|
227
|
+
expect(await runCiWorkflow({ args: { ciTokenSecret: "CI_GREEN_TOKEN" } } as unknown as CommandContext)).toBe(0);
|
|
228
|
+
const withToken = readFileSync(file, "utf-8");
|
|
229
|
+
expect(withToken.split("\n")[0]).toBe(`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --token-secret CI_GREEN_TOKEN (in the workspace root)`);
|
|
230
|
+
const steps = (parseYAMLDocument(withToken) as Yaml).jobs.tick.steps;
|
|
231
|
+
expect(steps[0].with.token).toBe("${{ secrets.CI_GREEN_TOKEN }}");
|
|
232
|
+
expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
233
|
+
|
|
234
|
+
const app = { args: { ciAppIdVar: "CI_GREEN_APP_ID", ciAppKeySecret: "CI_GREEN_APP_KEY" } } as unknown as CommandContext;
|
|
235
|
+
expect(await runCiWorkflow(app)).toBe(0);
|
|
236
|
+
expect(readFileSync(file, "utf-8").split("\n")[0]).toBe(
|
|
237
|
+
`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --app-id-var CI_GREEN_APP_ID --app-key-secret CI_GREEN_APP_KEY (in the workspace root)`,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Dropping the flag writes the bytes chant 0.108.0 wrote.
|
|
241
|
+
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
|
|
242
|
+
expect(readFileSync(file, "utf-8")).toBe(plain);
|
|
243
|
+
} finally {
|
|
244
|
+
cwd.mockRestore();
|
|
245
|
+
log.mockRestore();
|
|
246
|
+
error.mockRestore();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("refuses a half-given App, both kinds of token at once, and a name GitHub wouldn't accept", async () => {
|
|
251
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
252
|
+
git(root, "init", "-q");
|
|
253
|
+
writeFiles(root, { "chant.workspace.json": declaration([], { ci: { green: GREEN } }), ".github/workflows/ci.yml": CI });
|
|
254
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
255
|
+
const errors: string[] = [];
|
|
256
|
+
const error = vi.spyOn(console, "error").mockImplementation((s: unknown) => void errors.push(String(s)));
|
|
257
|
+
try {
|
|
258
|
+
const run = (args: Record<string, string>) => runCiWorkflow({ args } as unknown as CommandContext);
|
|
259
|
+
expect(await run({ ciAppIdVar: "APP_ID" })).toBe(1);
|
|
260
|
+
expect(errors.pop()).toMatch(/both the App's id and its key/);
|
|
261
|
+
expect(await run({ ciTokenSecret: "T", ciAppIdVar: "APP_ID", ciAppKeySecret: "APP_KEY" })).toBe(1);
|
|
262
|
+
expect(errors.pop()).toMatch(/one or the other/);
|
|
263
|
+
expect(await run({ ciTokenSecret: "ci-green token" })).toBe(1);
|
|
264
|
+
expect(errors.pop()).toMatch(/--token-secret takes a secret or variable name/);
|
|
265
|
+
expect(existsSync(join(root, CI_GREEN_WORKFLOW_FILE))).toBe(false);
|
|
266
|
+
} finally {
|
|
267
|
+
cwd.mockRestore();
|
|
268
|
+
error.mockRestore();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("refuses to write a workflow nothing but the schedule would trigger", async () => {
|
|
273
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
274
|
+
git(root, "init", "-q");
|
|
275
|
+
writeFiles(root, { "chant.workspace.json": declaration([{ name: "root", dir: ".", kind: "other", because: "x" }], { ci: { green: GREEN } }) });
|
|
276
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
277
|
+
const errors: string[] = [];
|
|
278
|
+
const error = vi.spyOn(console, "error").mockImplementation((s: unknown) => void errors.push(String(s)));
|
|
279
|
+
try {
|
|
280
|
+
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(1);
|
|
281
|
+
expect(errors.join("\n")).toMatch(/--workflow <name>/);
|
|
282
|
+
expect(existsSync(join(root, CI_GREEN_WORKFLOW_FILE))).toBe(false);
|
|
283
|
+
} finally {
|
|
284
|
+
cwd.mockRestore();
|
|
285
|
+
error.mockRestore();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root CI file that runs `chant ci tick` (#3573, ws-103; ws-042 for its
|
|
3
|
+
* place at the repository root, #2524 D14 for its header and record).
|
|
4
|
+
*
|
|
5
|
+
* `chant ci workflow` writes `.github/workflows/chant-ci-green.yml`. It runs
|
|
6
|
+
* the tick when a workflow that holds a required check run completes on the
|
|
7
|
+
* branch, so a result or a re-run is acted on within seconds, and every 15
|
|
8
|
+
* minutes as a backstop for a dropped or late event. One concurrency group
|
|
9
|
+
* that never cancels keeps ticks one at a time.
|
|
10
|
+
*
|
|
11
|
+
* GitHub's `workflow_run` names workflows, and `ci.green` names check runs,
|
|
12
|
+
* so the generator reads the repository's other workflow files and keeps
|
|
13
|
+
* each workflow with a job whose check-run name meets one of a required
|
|
14
|
+
* phase's patterns. A job's check run is its `name`, or its id, with
|
|
15
|
+
* ` (<matrix values>)` after it for a matrix job and ` / <job>` after it for
|
|
16
|
+
* a job that calls a reusable workflow; a `${{ }}` expression stands for
|
|
17
|
+
* anything. `--workflow <name>` adds a workflow the scan can't see.
|
|
18
|
+
*
|
|
19
|
+
* The tick runs the published chant that wrote the file, through npx.
|
|
20
|
+
* `--chant <command>` runs another one instead, and `--install <command>`
|
|
21
|
+
* adds a step that installs it, with setup-node caching npm's downloads when
|
|
22
|
+
* the workspace root has a `package-lock.json`. chant's own repository uses
|
|
23
|
+
* both to run its source at the commit it checks out.
|
|
24
|
+
*
|
|
25
|
+
* The tick pushes its tags with the credentials actions/checkout leaves in
|
|
26
|
+
* the clone, the Actions token by default. GitHub treats a new ref to a
|
|
27
|
+
* commit whose workflow files differ from the default branch's as an update
|
|
28
|
+
* to those workflows, and the Actions token can never hold the `workflows`
|
|
29
|
+
* permission, so it is refused there. `--token-secret <NAME>` checks out
|
|
30
|
+
* with `secrets.<NAME>` instead, and `--app-id-var <VAR> --app-key-secret
|
|
31
|
+
* <NAME>` mints a GitHub App token first and checks out with that. The
|
|
32
|
+
* tick's own `GITHUB_TOKEN`, which reads check runs, stays the Actions token:
|
|
33
|
+
* a fine-grained personal access token can't call the Checks API.
|
|
34
|
+
*
|
|
35
|
+
* Like a member's pipelines (./member-pipeline.ts), the file starts with the
|
|
36
|
+
* generated-file header naming the command, and the member whose directory
|
|
37
|
+
* the command runs in records it in its generated-file record, so `WSP081`
|
|
38
|
+
* allows one declarer.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
43
|
+
import { GENERATED_MARKER } from "../discovery/files";
|
|
44
|
+
import { parseYAMLDocument } from "../yaml";
|
|
45
|
+
import type { CiGreen } from "./declaration";
|
|
46
|
+
import { matchesCheckRun } from "./ci-green";
|
|
47
|
+
|
|
48
|
+
/** Where the workflow goes, relative to the repository root. */
|
|
49
|
+
export const CI_GREEN_WORKFLOW_FILE = ".github/workflows/chant-ci-green.yml";
|
|
50
|
+
export const CI_GREEN_WORKFLOW_NAME = "chant-ci-green";
|
|
51
|
+
|
|
52
|
+
/** One workflow file as the scan reads it. */
|
|
53
|
+
export interface WorkflowSource {
|
|
54
|
+
/** Relative to the repository root, such as `.github/workflows/ci.yml`. */
|
|
55
|
+
path: string;
|
|
56
|
+
text: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
60
|
+
const EXPRESSION = /\$\{\{[^}]*\}\}/g;
|
|
61
|
+
|
|
62
|
+
/** The check-run names a workflow's jobs report, as patterns: `*` stands for what an expression or a matrix fills in. */
|
|
63
|
+
export function jobCheckRuns(doc: unknown): string[] {
|
|
64
|
+
if (!isObject(doc) || !isObject(doc.jobs)) return [];
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
for (const [id, job] of Object.entries(doc.jobs)) {
|
|
67
|
+
if (!isObject(job)) continue;
|
|
68
|
+
const named = typeof job.name === "string" ? job.name : id;
|
|
69
|
+
const base = named.replace(EXPRESSION, "*");
|
|
70
|
+
out.push(base);
|
|
71
|
+
if (isObject(job.strategy) && job.strategy.matrix !== undefined && base === named) out.push(`${base} (*)`);
|
|
72
|
+
if (typeof job.uses === "string") out.push(`${base} / *`);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether two patterns can name the same check run: either matches the other with its `*` filled in. */
|
|
78
|
+
function meet(a: string, b: string): boolean {
|
|
79
|
+
return matchesCheckRun(a, b.replace(/\*/g, "x")) || matchesCheckRun(b, a.replace(/\*/g, "x"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The name `workflow_run` knows a workflow by: its `name`, or its path when it has none or the name is an expression. */
|
|
83
|
+
function workflowName(doc: unknown, path: string): string {
|
|
84
|
+
return isObject(doc) && typeof doc.name === "string" && !doc.name.includes("${{") ? doc.name : path;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WorkflowScan {
|
|
88
|
+
/** The workflows holding a required check run, sorted. */
|
|
89
|
+
workflows: string[];
|
|
90
|
+
/** Required patterns no job of any workflow meets. */
|
|
91
|
+
unmatched: { phase: string; pattern: string }[];
|
|
92
|
+
/** Workflow files that could not be parsed. */
|
|
93
|
+
unreadable: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Find the workflows that hold the required phases' check runs. */
|
|
97
|
+
export function workflowsHolding(green: CiGreen, sources: readonly WorkflowSource[]): WorkflowScan {
|
|
98
|
+
const patterns = green.require.flatMap((name) => green.phases.find((p) => p.name === name)!.runs.map((pattern) => ({ phase: name, pattern })));
|
|
99
|
+
const workflows = new Set<string>();
|
|
100
|
+
const met = new Set<string>();
|
|
101
|
+
const unreadable: string[] = [];
|
|
102
|
+
for (const s of sources) {
|
|
103
|
+
let doc: unknown;
|
|
104
|
+
try {
|
|
105
|
+
doc = parseYAMLDocument(s.text);
|
|
106
|
+
} catch {
|
|
107
|
+
unreadable.push(s.path);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const runs = jobCheckRuns(doc);
|
|
111
|
+
for (const p of patterns) {
|
|
112
|
+
if (!runs.some((r) => meet(p.pattern, r))) continue;
|
|
113
|
+
workflows.add(workflowName(doc, s.path));
|
|
114
|
+
met.add(`${p.phase}\0${p.pattern}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
workflows: [...workflows].sort(),
|
|
119
|
+
unmatched: patterns.filter((p) => !met.has(`${p.phase}\0${p.pattern}`)),
|
|
120
|
+
unreadable,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The repository's GitHub workflow files, other than `skip`. */
|
|
125
|
+
export function readWorkflowSources(repoRoot: string, skip: string): WorkflowSource[] {
|
|
126
|
+
const dir = join(repoRoot, ".github", "workflows");
|
|
127
|
+
if (!existsSync(dir)) return [];
|
|
128
|
+
return readdirSync(dir)
|
|
129
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
130
|
+
.sort()
|
|
131
|
+
.map((f) => `.github/workflows/${f}`)
|
|
132
|
+
.filter((p) => p !== skip)
|
|
133
|
+
.map((path) => ({ path, text: readFileSync(join(repoRoot, path), "utf-8") }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const q = (s: string) => JSON.stringify(s);
|
|
137
|
+
|
|
138
|
+
export interface WorkflowInput {
|
|
139
|
+
green: CiGreen;
|
|
140
|
+
/** The workflows whose completion triggers a tick. */
|
|
141
|
+
workflows: readonly string[];
|
|
142
|
+
/** The chant the tick runs, `@intentius/chant` at this version. */
|
|
143
|
+
chantVersion: string;
|
|
144
|
+
/**
|
|
145
|
+
* The command that runs chant, with `ci tick` after it, in place of
|
|
146
|
+
* `npx --yes @intentius/chant@<chantVersion>`. chant's own repository runs
|
|
147
|
+
* its source this way, so the tick tests the commit it checks out.
|
|
148
|
+
*/
|
|
149
|
+
chant?: string;
|
|
150
|
+
/** A step before the tick that installs what `chant` needs, run where the tick runs. */
|
|
151
|
+
install?: string;
|
|
152
|
+
/** With `install`, the `package-lock.json` setup-node caches npm's downloads by, from the repository root. */
|
|
153
|
+
npmLock?: string;
|
|
154
|
+
/** The workspace root relative to the repository root, `.` for the root itself. */
|
|
155
|
+
workspaceRoot: string;
|
|
156
|
+
/** How the tag push authenticates, when not with the Actions token. */
|
|
157
|
+
push?: PushToken;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A token the checkout persists for the tick's `git push`: a repository
|
|
162
|
+
* secret, or a GitHub App's installation token minted from its id (an
|
|
163
|
+
* Actions variable) and private key (a secret).
|
|
164
|
+
*/
|
|
165
|
+
export type PushToken = { secret: string } | { appIdVar: string; appKeySecret: string };
|
|
166
|
+
|
|
167
|
+
/** A secret or variable name GitHub accepts: letters, digits and underscores, not starting with a digit. */
|
|
168
|
+
export const ACTIONS_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
169
|
+
|
|
170
|
+
/** The workflow's YAML, without the generated-file header. Deterministic: the same input writes the same bytes. */
|
|
171
|
+
export function renderCiGreenWorkflow(input: WorkflowInput): string {
|
|
172
|
+
const { green } = input;
|
|
173
|
+
const where = input.workspaceRoot === "." ? [] : [` working-directory: ${q(input.workspaceRoot)}`];
|
|
174
|
+
const cache = input.install && input.npmLock ? [" cache: npm", ...(input.npmLock === "package-lock.json" ? [] : [` cache-dependency-path: ${q(input.npmLock)}`])] : [];
|
|
175
|
+
const tick = input.chant ? q(`${input.chant} ci tick`) : `npx --yes @intentius/chant@${input.chantVersion} ci tick`;
|
|
176
|
+
const push = input.push;
|
|
177
|
+
const mint =
|
|
178
|
+
push && "appIdVar" in push
|
|
179
|
+
? [
|
|
180
|
+
" - name: Mint a GitHub App token that may push tags to commits that change workflows",
|
|
181
|
+
" id: app-token",
|
|
182
|
+
" uses: actions/create-github-app-token@v2",
|
|
183
|
+
" with:",
|
|
184
|
+
` app-id: \${{ vars.${push.appIdVar} }}`,
|
|
185
|
+
` private-key: \${{ secrets.${push.appKeySecret} }}`,
|
|
186
|
+
]
|
|
187
|
+
: [];
|
|
188
|
+
const token = !push ? [] : ["secret" in push ? ` token: \${{ secrets.${push.secret} }}` : " token: ${{ steps.app-token.outputs.token }}"];
|
|
189
|
+
const lines = [
|
|
190
|
+
`name: ${CI_GREEN_WORKFLOW_NAME}`,
|
|
191
|
+
"",
|
|
192
|
+
"# Tags each commit on the branch that passed its required phases ci/green/<sha>,",
|
|
193
|
+
"# and a green commit that later fails one ci/revoked/<sha> (#3573).",
|
|
194
|
+
"on:",
|
|
195
|
+
" workflow_run:",
|
|
196
|
+
" workflows:",
|
|
197
|
+
...input.workflows.map((w) => ` - ${q(w)}`),
|
|
198
|
+
" types:",
|
|
199
|
+
" - completed",
|
|
200
|
+
" branches:",
|
|
201
|
+
` - ${q(green.branch)}`,
|
|
202
|
+
" schedule:",
|
|
203
|
+
" - cron: \"*/15 * * * *\"",
|
|
204
|
+
" workflow_dispatch: {}",
|
|
205
|
+
"",
|
|
206
|
+
"# One tick at a time, and a tick in progress is never cancelled.",
|
|
207
|
+
"concurrency:",
|
|
208
|
+
` group: ${CI_GREEN_WORKFLOW_NAME}`,
|
|
209
|
+
" cancel-in-progress: false",
|
|
210
|
+
"",
|
|
211
|
+
"permissions:",
|
|
212
|
+
" contents: write",
|
|
213
|
+
" checks: read",
|
|
214
|
+
"",
|
|
215
|
+
"jobs:",
|
|
216
|
+
" tick:",
|
|
217
|
+
" runs-on: ubuntu-latest",
|
|
218
|
+
" timeout-minutes: 15",
|
|
219
|
+
" steps:",
|
|
220
|
+
...mint,
|
|
221
|
+
" - uses: actions/checkout@v6",
|
|
222
|
+
" with:",
|
|
223
|
+
` ref: ${q(green.branch)}`,
|
|
224
|
+
" fetch-depth: 0",
|
|
225
|
+
...token,
|
|
226
|
+
" - uses: actions/setup-node@v6",
|
|
227
|
+
" with:",
|
|
228
|
+
" node-version: \"24\"",
|
|
229
|
+
...cache,
|
|
230
|
+
...(input.install ? [" - name: Install what the tick runs", ...where, ` run: ${q(input.install)}`] : []),
|
|
231
|
+
" - name: Tag the commits that turned green, revoke the ones that turned red",
|
|
232
|
+
...where,
|
|
233
|
+
" env:",
|
|
234
|
+
" GITHUB_TOKEN: ${{ github.token }}",
|
|
235
|
+
" GIT_COMMITTER_NAME: github-actions[bot]",
|
|
236
|
+
" GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com",
|
|
237
|
+
` run: ${tick}`,
|
|
238
|
+
];
|
|
239
|
+
return lines.join("\n") + "\n";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The header line naming the command that regenerates the file, and where to run it. */
|
|
243
|
+
export function ciGreenHeader(command: string, where: "member" | "root"): string {
|
|
244
|
+
return `# ${GENERATED_MARKER}. Regenerate with: ${command} (in the ${where === "member" ? "member's directory" : "workspace root"})\n`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Write the file, creating its directory. Returns the path relative to the current directory. */
|
|
248
|
+
export function writeWorkflowFile(target: string, text: string): string {
|
|
249
|
+
mkdirSync(dirname(resolve(target)), { recursive: true });
|
|
250
|
+
writeFileSync(target, text);
|
|
251
|
+
return relative(process.cwd(), target).split(sep).join("/") || target;
|
|
252
|
+
}
|