@intentius/chant 0.124.30 → 0.124.31
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/main.d.ts.map +1 -1
- package/dist/cli/main.js +38 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +50 -0
- package/dist/cli/registry.d.ts +18 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +12 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +22 -3
- package/dist/op/redact.d.ts +34 -0
- package/dist/op/redact.d.ts.map +1 -0
- package/dist/op/redact.js +94 -0
- package/dist/op/step-console.d.ts +2 -0
- package/dist/op/step-console.d.ts.map +1 -1
- package/dist/op/step-console.js +5 -2
- package/dist/pr-forge.d.ts +17 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +12 -6
- package/dist/pr-report-url.d.ts +8 -0
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +47 -1
- package/dist/wave-runtime.mjs +159 -159
- package/dist/workspace/ci-run-index.d.ts +96 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -0
- package/dist/workspace/ci-run-index.js +162 -0
- package/dist/workspace/ci-run-index.schema.json +50 -0
- package/dist/workspace/ci-run.d.ts +84 -7
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +241 -14
- package/dist/workspace/ci-runner-key.d.ts +120 -0
- package/dist/workspace/ci-runner-key.d.ts.map +1 -0
- package/dist/workspace/ci-runner-key.js +217 -0
- package/dist/workspace/ci-secrets.d.ts +65 -0
- package/dist/workspace/ci-secrets.d.ts.map +1 -0
- package/dist/workspace/ci-secrets.js +211 -0
- package/dist/workspace/ci-serve.d.ts +13 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +69 -13
- package/dist/workspace/reason-codes.d.ts +6 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +7 -2
- package/dist/workspace/trust/trust-write.d.ts +109 -0
- package/dist/workspace/trust/trust-write.d.ts.map +1 -0
- package/dist/workspace/trust/trust-write.js +320 -0
- package/dist/workspace/trust-write.schema.json +265 -0
- package/package.json +1 -1
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +38 -4
- package/src/cli/parse-args.ts +36 -0
- package/src/cli/registry.ts +18 -0
- package/src/op/local-executor.ts +31 -3
- package/src/op/redact.ts +93 -0
- package/src/op/step-console.ts +7 -2
- package/src/pr-forge.test.ts +29 -0
- package/src/pr-forge.ts +30 -6
- package/src/pr-report-url.ts +53 -1
- package/src/workspace/ci-run-index.schema.json +50 -0
- package/src/workspace/ci-run-index.test.ts +353 -0
- package/src/workspace/ci-run-index.ts +192 -0
- package/src/workspace/ci-run.test.ts +337 -37
- package/src/workspace/ci-run.ts +277 -15
- package/src/workspace/ci-runner-key.test.ts +166 -0
- package/src/workspace/ci-runner-key.ts +250 -0
- package/src/workspace/ci-secrets.test.ts +106 -0
- package/src/workspace/ci-secrets.ts +213 -0
- package/src/workspace/ci-serve.test.ts +136 -37
- package/src/workspace/ci-serve.ts +66 -13
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +7 -2
- package/src/workspace/trust/trust-write.test.ts +179 -0
- package/src/workspace/trust/trust-write.ts +377 -0
- package/src/workspace/trust-write.schema.json +265 -0
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
|
-
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { afterAll, describe, expect, test } from "vitest";
|
|
16
|
-
import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
16
|
+
import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
17
17
|
import { ciServeInterval, ciServeKey, ciServePass, formatCiServeEvent, readCiServeState, type CiServeEvent, type CiServeForge, type CiServeOptions } from "./ci-serve";
|
|
18
|
+
import { ciRun } from "./ci-run";
|
|
18
19
|
import { acquireLease, releaseLease } from "../lifecycle/lease";
|
|
19
20
|
import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
|
|
20
21
|
import { parseArgs } from "../cli/parse-args";
|
|
21
|
-
import type
|
|
22
|
+
import { forgejoForge, githubForge, type ForgeFetch, type OpenPullRequest } from "../pr-forge";
|
|
22
23
|
|
|
23
24
|
afterAll(cleanScratch);
|
|
24
25
|
|
|
@@ -30,11 +31,14 @@ function commit(root: string, file: string): string {
|
|
|
30
31
|
return git(root, "rev-parse", "HEAD");
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
const
|
|
34
|
+
/** The repository every test starts from, built once: a checkout on main with a bare origin and member `app`. */
|
|
35
|
+
const workspaceTemplate = repoFixture(() => {
|
|
36
|
+
const top = scratchDir("chant-ci-serve-template-");
|
|
37
|
+
const origin = join(top, "origin");
|
|
38
|
+
const root = join(top, "root");
|
|
39
|
+
mkdirSync(origin);
|
|
40
|
+
mkdirSync(root);
|
|
36
41
|
git(origin, "init", "-q", "--bare");
|
|
37
|
-
const root = scratchDir("chant-ci-serve-");
|
|
38
42
|
git(root, "init", "-q");
|
|
39
43
|
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
40
44
|
writeFiles(root, {
|
|
@@ -43,9 +47,17 @@ function workspace(): { root: string; app: string; main: string } {
|
|
|
43
47
|
"app/a.txt": "a\n",
|
|
44
48
|
});
|
|
45
49
|
git(root, "remote", "add", "origin", origin);
|
|
46
|
-
|
|
50
|
+
commit(root, "app/init.txt");
|
|
47
51
|
git(root, "push", "-q", "origin", "main");
|
|
48
|
-
return
|
|
52
|
+
return top;
|
|
53
|
+
}, "chant-ci-serve-");
|
|
54
|
+
|
|
55
|
+
/** A copy of the template, its origin pointed at the copy of the bare one. */
|
|
56
|
+
function workspace(): { root: string; app: string; main: string } {
|
|
57
|
+
const top = workspaceTemplate();
|
|
58
|
+
const root = join(top, "root");
|
|
59
|
+
git(root, "remote", "set-url", "origin", join(top, "origin"));
|
|
60
|
+
return { root, app: join(root, "app"), main: git(root, "rev-parse", "HEAD") };
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
/** A pull request's head commit, on a branch off main, pushed to the forge's refs/pull/<n>/head. */
|
|
@@ -86,9 +98,17 @@ function fakeChant(): string {
|
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
/** A forge whose open pull requests and branch head the test sets. */
|
|
89
|
-
function fakeForge(head: string): CiServeForge & { head: string; prs: OpenPullRequest[]; polls: number } {
|
|
101
|
+
function fakeForge(head: string): CiServeForge & { head: string; prs: OpenPullRequest[]; polls: number; merges: Map<string, number | "throw">; asked: string[] } {
|
|
90
102
|
const f = {
|
|
91
103
|
head,
|
|
104
|
+
merges: new Map<string, number | "throw">(),
|
|
105
|
+
asked: [] as string[],
|
|
106
|
+
async pullRequestFor(sha: string) {
|
|
107
|
+
f.asked.push(sha);
|
|
108
|
+
const n = f.merges.get(sha);
|
|
109
|
+
if (n === "throw") throw new Error("forge unavailable");
|
|
110
|
+
return n ?? null;
|
|
111
|
+
},
|
|
92
112
|
prs: [] as OpenPullRequest[],
|
|
93
113
|
polls: 0,
|
|
94
114
|
async openPullRequests(base: string) {
|
|
@@ -185,6 +205,70 @@ describe("chant ci serve (#4095)", () => {
|
|
|
185
205
|
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
186
206
|
});
|
|
187
207
|
|
|
208
|
+
test("an apply gets the number of the pull request whose merge commit it is (#4112)", async () => {
|
|
209
|
+
const { root, app, main } = workspace();
|
|
210
|
+
const forge = fakeForge(main);
|
|
211
|
+
const seen: Array<number | undefined> = [];
|
|
212
|
+
const { opts } = serve(app, forge, { run: (req) => (seen.push(req.pr), ciRun(req)) });
|
|
213
|
+
await ciServePass(opts);
|
|
214
|
+
const merged = commit(root, "app/merged.txt");
|
|
215
|
+
git(root, "push", "-q", "origin", "main");
|
|
216
|
+
forge.head = merged;
|
|
217
|
+
forge.merges.set(merged, 17);
|
|
218
|
+
|
|
219
|
+
const pass = await ciServePass(opts);
|
|
220
|
+
expect(forge.asked).toEqual([merged]);
|
|
221
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, pr: 17, status: "ok" })]);
|
|
222
|
+
expect(seen).toEqual([17]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/** A serve over a repository whose baseline pass is done, with the `pr` each run was asked for in `seen`. */
|
|
226
|
+
async function mergeWithoutPr() {
|
|
227
|
+
const { root, app, main } = workspace();
|
|
228
|
+
const forge = fakeForge(main);
|
|
229
|
+
const seen: Array<number | undefined> = [];
|
|
230
|
+
const { opts } = serve(app, forge, { run: (req) => (seen.push(req.pr), ciRun(req)) });
|
|
231
|
+
await ciServePass(opts);
|
|
232
|
+
return { root, forge, seen, opts };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test("an apply with no pull request found runs without --pr (#4112)", async () => {
|
|
236
|
+
const { root, forge, seen, opts } = await mergeWithoutPr();
|
|
237
|
+
const direct = commit(root, "app/direct.txt");
|
|
238
|
+
git(root, "push", "-q", "origin", "main");
|
|
239
|
+
forge.head = direct;
|
|
240
|
+
const pass = await ciServePass(opts);
|
|
241
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: direct, status: "ok" })]);
|
|
242
|
+
expect(pass.jobs[0]!.pr).toBeUndefined();
|
|
243
|
+
expect(seen).toEqual([undefined]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("an apply whose pull request lookup fails runs without --pr (#4112)", async () => {
|
|
247
|
+
const { root, forge, seen, opts } = await mergeWithoutPr();
|
|
248
|
+
const broken = commit(root, "app/broken.txt");
|
|
249
|
+
git(root, "push", "-q", "origin", "main");
|
|
250
|
+
forge.head = broken;
|
|
251
|
+
forge.merges.set(broken, "throw");
|
|
252
|
+
const pass = await ciServePass(opts);
|
|
253
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: broken, status: "ok" })]);
|
|
254
|
+
expect(pass.jobs[0]!.pr).toBeUndefined();
|
|
255
|
+
expect(seen).toEqual([undefined]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("the forge clients look a merge commit up with one GET each (#4112)", async () => {
|
|
259
|
+
const sha = "c".repeat(40);
|
|
260
|
+
const calls: string[] = [];
|
|
261
|
+
const fetch = (body: unknown): ForgeFetch => async (url, init) => {
|
|
262
|
+
calls.push(`${init.method} ${new URL(url).pathname}`);
|
|
263
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(body) };
|
|
264
|
+
};
|
|
265
|
+
const gh: CiServeForge = githubForge({ apiBase: "https://api.github.com", repo: "demo/estate", token: "t", fetch: fetch([{ number: 5, merged_at: "2026-10-01T00:00:00Z", merge_commit_sha: sha }]) }) as unknown as CiServeForge;
|
|
266
|
+
expect(await gh.pullRequestFor!(sha)).toBe(5);
|
|
267
|
+
const fj: CiServeForge = forgejoForge({ apiBase: "http://forgejo.localhost/api/v1", host: "forgejo.localhost", repo: "demo/estate", token: "t", fetch: fetch({ number: 6 }) }) as unknown as CiServeForge;
|
|
268
|
+
expect(await fj.pullRequestFor!(sha)).toBe(6);
|
|
269
|
+
expect(calls).toEqual([`GET /repos/demo/estate/commits/${sha}/pulls`, `GET /api/v1/repos/demo/estate/commits/${sha}/pull`]);
|
|
270
|
+
});
|
|
271
|
+
|
|
188
272
|
test("the group's lease held elsewhere holds the apply until it is released", async () => {
|
|
189
273
|
const { root, app, main } = workspace();
|
|
190
274
|
const forge = fakeForge(main);
|
|
@@ -209,33 +293,48 @@ describe("chant ci serve (#4095)", () => {
|
|
|
209
293
|
expect((await acquireLease(group, "after", { cwd: app, mode: "claim" })).acquired).toBe(true);
|
|
210
294
|
});
|
|
211
295
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
296
|
+
describe("a failed apply", () => {
|
|
297
|
+
// The failing apply runs once in beforeAll, and the two tests read what it left, so no test
|
|
298
|
+
// runs two jobs. A file's beforeAll is not counted in the unit budget (#2817).
|
|
299
|
+
let w: ReturnType<typeof workspace>;
|
|
300
|
+
let forge: ReturnType<typeof fakeForge>;
|
|
301
|
+
let failing: ReturnType<typeof serve>;
|
|
302
|
+
let merged: string;
|
|
303
|
+
let failed: Awaited<ReturnType<typeof ciServePass>>;
|
|
304
|
+
let record: string;
|
|
305
|
+
|
|
306
|
+
beforeAll(async () => {
|
|
307
|
+
w = workspace();
|
|
308
|
+
forge = fakeForge(w.main);
|
|
309
|
+
failing = serve(w.app, forge, { hostEnv: { FAKE_EXIT: "3", FAKE_ATTEMPT: "1" } });
|
|
310
|
+
await ciServePass(failing.opts);
|
|
311
|
+
merged = commit(w.root, "app/merged.txt");
|
|
312
|
+
git(w.root, "push", "-q", "origin", "main");
|
|
313
|
+
forge.head = merged;
|
|
314
|
+
failed = await ciServePass(failing.opts);
|
|
315
|
+
record = join(failing.state, "resume", `${prApplyRecordKey("prod", merged, "app")}.json`);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("stays failed, and keeps its resume record", async () => {
|
|
319
|
+
expect(failed.jobs).toEqual([expect.objectContaining({ job: "apply", status: "fail", attempts: 1, failure: expect.stringContaining("command-failed") })]);
|
|
320
|
+
expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "1" });
|
|
321
|
+
expect(existsSync(join(failed.jobs[0]!.report!, "resume-in.json"))).toBe(false);
|
|
322
|
+
|
|
323
|
+
// Without --rerun-failed the failed apply is not run again.
|
|
324
|
+
expect((await ciServePass(failing.opts)).jobs).toEqual([]);
|
|
325
|
+
});
|
|
220
326
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
expect(again.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: main, status: "ok", attempts: 2 })]);
|
|
233
|
-
expect(ofType(retry.events, "started")[0]).toMatchObject({ attempt: 2, resume: record });
|
|
234
|
-
// The job read the first attempt's record, and left its own.
|
|
235
|
-
expect(JSON.parse(readFileSync(join(again.jobs[0]!.report!, "resume-in.json"), "utf-8"))).toEqual({ attempt: "1" });
|
|
236
|
-
expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "2" });
|
|
237
|
-
// A passed apply is not run again, --rerun-failed or not.
|
|
238
|
-
expect((await ciServePass(retry.opts)).jobs).toEqual([]);
|
|
327
|
+
test("--rerun-failed runs it again with its resume record", async () => {
|
|
328
|
+
const retry = serve(w.app, forge, { stateDir: failing.state, rerunFailed: true, hostEnv: { FAKE_EXIT: "0", FAKE_ATTEMPT: "2" } });
|
|
329
|
+
const again = await ciServePass(retry.opts);
|
|
330
|
+
expect(again.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: w.main, status: "ok", attempts: 2 })]);
|
|
331
|
+
expect(ofType(retry.events, "started")[0]).toMatchObject({ attempt: 2, resume: record });
|
|
332
|
+
// The job read the first attempt's record, and left its own.
|
|
333
|
+
expect(JSON.parse(readFileSync(join(again.jobs[0]!.report!, "resume-in.json"), "utf-8"))).toEqual({ attempt: "1" });
|
|
334
|
+
expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "2" });
|
|
335
|
+
// A passed apply is not run again, --rerun-failed or not.
|
|
336
|
+
expect((await ciServePass(retry.opts)).jobs).toEqual([]);
|
|
337
|
+
});
|
|
239
338
|
});
|
|
240
339
|
|
|
241
340
|
test("--max-jobs holds the jobs over the limit for the next poll", async () => {
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* the one `chant operator` holds) named for `prApplyGroup`, renews it while
|
|
16
16
|
* it runs and releases it after. An apply that finds it held waits for a
|
|
17
17
|
* later poll, as a forge's concurrency group queues it.
|
|
18
|
+
* - Merged pull request. An apply asks the forge for the pull request whose
|
|
19
|
+
* merge commit the new head is (`pullRequestFor`, as pr-apply does) and
|
|
20
|
+
* passes it as `--pr`, so its commit status link and run index use
|
|
21
|
+
* `<pr>/<commit>`. With none found it runs without `--pr`.
|
|
18
22
|
* - Resume records. Each apply gets `<state>/resume/<prApplyRecordKey>.json`
|
|
19
23
|
* as its `--resume` record, so a re-run of a failed apply at the same
|
|
20
24
|
* commit finishes under the approval it has. This replaces the forge's CI
|
|
@@ -34,16 +38,17 @@
|
|
|
34
38
|
|
|
35
39
|
import { execFileSync } from "node:child_process";
|
|
36
40
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
37
|
-
import {
|
|
41
|
+
import { join, resolve } from "node:path";
|
|
38
42
|
import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
|
|
39
43
|
import { forgeFromEnv, type OpenPullRequest } from "../pr-forge";
|
|
40
44
|
import type { CommandContext } from "../cli/registry";
|
|
41
45
|
import { formatError, formatWarning } from "../cli/format";
|
|
42
46
|
import { acquireLease, currentHolderId, releaseLease, DEFAULT_LEASE_TTL_MS, type LeaseRecord } from "../lifecycle/lease";
|
|
43
47
|
import { parseDuration } from "../op/duration";
|
|
44
|
-
import { CI_JOB_OPS, ciJobPlace, ciRun, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
|
|
48
|
+
import { CI_JOB_OPS, ciJobPlace, ciSecretsOf, ciRun, ciStatusFromEnv, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
|
|
45
49
|
import { RUNNER_KEY_ENV } from "./ci-green";
|
|
46
50
|
import { gitTop } from "./tree";
|
|
51
|
+
import { ciServeRunnerKey, formatRunnerKeySetup, setupRunnerKey } from "./ci-runner-key";
|
|
47
52
|
|
|
48
53
|
/** The forges `ci serve` polls. GitLab is later. */
|
|
49
54
|
export type CiServeForgeKind = "github" | "forgejo";
|
|
@@ -52,6 +57,8 @@ export type CiServeForgeKind = "github" | "forgejo";
|
|
|
52
57
|
export interface CiServeForge {
|
|
53
58
|
openPullRequests(base: string): Promise<OpenPullRequest[]>;
|
|
54
59
|
branchHead(branch: string): Promise<string | undefined>;
|
|
60
|
+
/** The merged pull request that produced a commit, as pr-apply finds it (`pullRequestFor`). Without it an apply gets no `--pr`. */
|
|
61
|
+
pullRequestFor?(sha: string): Promise<number | null>;
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
/** A job the forge's state calls for: the plan of a pull request's head, or the apply of a target-branch commit. */
|
|
@@ -347,17 +354,20 @@ async function runJob(
|
|
|
347
354
|
t: CiServeTrigger,
|
|
348
355
|
paths: { runId: string; report: string; resume?: string },
|
|
349
356
|
held: { lease: LeaseRecord; holder: string; ttlMs: number; now: () => Date } | undefined,
|
|
350
|
-
): Promise<Pick<CiServeJobState, "status" | "failure" | "report">> {
|
|
357
|
+
): Promise<Pick<CiServeJobState, "status" | "failure" | "report" | "pr">> {
|
|
351
358
|
const renew = held
|
|
352
359
|
? setInterval(() => {
|
|
353
360
|
void acquireLease(place.group, held.holder, { cwd: opts.cwd, ttlMs: held.ttlMs, mode: "renew", token: held.lease.token, now: held.now }).catch(() => undefined);
|
|
354
361
|
}, Math.max(1000, Math.floor(held.ttlMs / 3)))
|
|
355
362
|
: undefined;
|
|
356
363
|
renew?.unref?.();
|
|
364
|
+
let merged: number | undefined;
|
|
357
365
|
try {
|
|
358
366
|
// The pull request's head is on the forge's refs/pull/<n>/head, the target's on the branch.
|
|
359
367
|
tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", t.job === "plan" ? `refs/pull/${t.pr}/head` : place.branch]);
|
|
360
368
|
const hostEnv = { ...(opts.job?.hostEnv ?? process.env), GITHUB_REPOSITORY: opts.repo };
|
|
369
|
+
// An apply is for a merge: pass the pull request whose merge commit it is, so the commit status link and the run index use `<pr>/<commit>`. None found, or a forge that errs, is today's behaviour.
|
|
370
|
+
if (t.job === "apply" && opts.client.pullRequestFor) merged = (await opts.client.pullRequestFor(t.commit).catch(() => null)) ?? undefined;
|
|
361
371
|
const run = opts.run ?? ciRun;
|
|
362
372
|
const r = await run({
|
|
363
373
|
...opts.job,
|
|
@@ -367,16 +377,16 @@ async function runJob(
|
|
|
367
377
|
env: opts.env,
|
|
368
378
|
forge: opts.forge,
|
|
369
379
|
base: t.base,
|
|
370
|
-
...(t.job === "plan" ? { pr: t.pr } : {}),
|
|
380
|
+
...(t.job === "plan" ? { pr: t.pr } : merged !== undefined ? { pr: merged } : {}),
|
|
371
381
|
commit: t.commit,
|
|
372
382
|
output: paths.report,
|
|
373
383
|
runId: paths.runId,
|
|
374
384
|
...(paths.resume ? { resumeRecord: paths.resume } : {}),
|
|
375
385
|
});
|
|
376
386
|
const why = r.record.failure ? `${r.record.failure.code}: ${r.record.failure.reason}` : r.failure ? `${r.failure.phase}: ${r.failure.error.split("\n")[0]}` : undefined;
|
|
377
|
-
return { status: r.status === "ok" ? "ok" : "fail", ...(why && r.status !== "ok" ? { failure: why } : {}), ...(r.report ? { report: r.report } : {}) };
|
|
387
|
+
return { status: r.status === "ok" ? "ok" : "fail", ...(merged !== undefined ? { pr: merged } : {}), ...(why && r.status !== "ok" ? { failure: why } : {}), ...(r.report ? { report: r.report } : {}) };
|
|
378
388
|
} catch (err) {
|
|
379
|
-
return { status: "fail", failure: message(err) };
|
|
389
|
+
return { status: "fail", ...(merged !== undefined ? { pr: merged } : {}), failure: message(err) };
|
|
380
390
|
} finally {
|
|
381
391
|
if (renew) clearInterval(renew);
|
|
382
392
|
if (held) await releaseLease(place.group, held.holder, held.lease.token, { cwd: opts.cwd }).catch(() => false);
|
|
@@ -439,10 +449,37 @@ export function formatCiServeEvent(e: CiServeEvent): string {
|
|
|
439
449
|
|
|
440
450
|
// ── The command ──────────────────────────────────────────────────────────────
|
|
441
451
|
|
|
452
|
+
/**
|
|
453
|
+
* `chant ci serve|run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]` (#4099):
|
|
454
|
+
* make the host's runner key when there is none, print its public half on
|
|
455
|
+
* stdout, and say whether the target's head lists it. With --trust, commit
|
|
456
|
+
* the runner entry on a branch of its own and push it for review.
|
|
457
|
+
*/
|
|
458
|
+
export function runnerKeySetupCommand(ctx: CommandContext): number {
|
|
459
|
+
const { args } = ctx;
|
|
460
|
+
const cwd = process.cwd();
|
|
461
|
+
const top = gitTop(cwd);
|
|
462
|
+
if (!top) throw new CiRunError("the runner key is kept in the repository's git directory, and this directory is not in a git repository");
|
|
463
|
+
if (args.ciPrincipal !== undefined && !args.trustKey) throw new CiRunError("--principal names who --trust lists the key for; give --trust with it");
|
|
464
|
+
const branch = args.branch ?? "main";
|
|
465
|
+
const setup = setupRunnerKey({
|
|
466
|
+
top,
|
|
467
|
+
stateDir: args.statePath ? resolve(args.statePath) : defaultCiServeStateDir(cwd),
|
|
468
|
+
branch,
|
|
469
|
+
...(args.ciPrincipal ? { principal: args.ciPrincipal } : {}),
|
|
470
|
+
...(args.trustKey ? { trust: true } : {}),
|
|
471
|
+
...(args.by ? { by: args.by } : {}),
|
|
472
|
+
});
|
|
473
|
+
console.log(setup.key.publicKey);
|
|
474
|
+
console.error(formatRunnerKeySetup(setup, branch));
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
442
478
|
const USAGE =
|
|
479
|
+
"chant ci serve --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>] | " +
|
|
443
480
|
"chant ci serve --forge forgejo|github --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
|
|
444
|
-
"[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] " +
|
|
445
|
-
"[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--key <runner key.pem>]";
|
|
481
|
+
"[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
|
|
482
|
+
"[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [--unsigned]";
|
|
446
483
|
|
|
447
484
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
448
485
|
|
|
@@ -458,24 +495,37 @@ export function ciServeInterval(value: string | undefined): number {
|
|
|
458
495
|
export async function runCiServe(ctx: CommandContext): Promise<number> {
|
|
459
496
|
const { args } = ctx;
|
|
460
497
|
try {
|
|
498
|
+
if (args.setupKey) return runnerKeySetupCommand(ctx);
|
|
461
499
|
const forge = args.forge;
|
|
462
500
|
if (forge !== "forgejo" && forge !== "github") throw new CiRunError(`--forge names the forge ci serve polls: --forge forgejo|github${forge ? `, not ${JSON.stringify(forge)}` : ""}`);
|
|
463
501
|
if (!args.ciRepo) throw new CiRunError("--repo names the repository whose pull requests ci serve polls: --repo <owner/repo>");
|
|
464
502
|
if (!args.env) throw new CiRunError("--env names the environment the pipeline is for: --env <env>");
|
|
465
503
|
for (const name of args.passEnv ?? []) if (!ENV_NAME.test(name)) throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
|
|
466
504
|
const intervalMs = ciServeInterval(args.interval);
|
|
467
|
-
const
|
|
468
|
-
const
|
|
505
|
+
const cwd = process.cwd();
|
|
506
|
+
const top = gitTop(cwd);
|
|
507
|
+
if (!top) throw new CiRunError("ci serve runs in a clone of the repository it serves, and this directory is not in a git repository");
|
|
508
|
+
const stateDir = args.statePath ? resolve(args.statePath) : defaultCiServeStateDir(cwd);
|
|
509
|
+
const branch = args.branch ?? "main";
|
|
510
|
+
// #4099: the host's key, checked against .chant/trust.json at the target's head before the first poll.
|
|
511
|
+
const keyed = ciServeRunnerKey({ top, stateDir, branch, ...(args.key !== undefined ? { keyFile: resolve(args.key) } : {}), envKey: process.env[RUNNER_KEY_ENV] || undefined, unsigned: args.unsigned });
|
|
512
|
+
const fromFlag = keyed.fromFlag;
|
|
513
|
+
const runnerKey = keyed.pem;
|
|
514
|
+
console.error(`ci serve: ${runnerKey === undefined ? "runs are not signed (--unsigned)" : `signing runs with the runner key from ${keyed.source}`}`);
|
|
515
|
+
// #4096: read once, so a loose or broken secrets file stops the serve before its first poll.
|
|
516
|
+
const secrets = ciSecretsOf(args.ciSecrets);
|
|
469
517
|
const hostEnv = { ...process.env, GITHUB_REPOSITORY: args.ciRepo };
|
|
470
518
|
const client = forgeFromEnv(forge, hostEnv);
|
|
519
|
+
const { status, warning } = ciStatusFromEnv({ forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
|
|
520
|
+
if (warning) console.error(formatWarning({ message: warning }));
|
|
471
521
|
if (!client.openPullRequests || !client.branchHead) throw new CiRunError(`the ${forge} client cannot list pull requests`);
|
|
472
522
|
const opts: CiServeOptions = {
|
|
473
|
-
cwd
|
|
523
|
+
cwd,
|
|
474
524
|
forge,
|
|
475
525
|
repo: args.ciRepo,
|
|
476
526
|
env: args.env,
|
|
477
|
-
client: { openPullRequests: client.openPullRequests.bind(client), branchHead: client.branchHead.bind(client) },
|
|
478
|
-
|
|
527
|
+
client: { openPullRequests: client.openPullRequests.bind(client), branchHead: client.branchHead.bind(client), pullRequestFor: client.pullRequestFor.bind(client) },
|
|
528
|
+
stateDir,
|
|
479
529
|
...(args.maxJobs ? { maxJobs: args.maxJobs } : {}),
|
|
480
530
|
...(args.ciAuthors?.length ? { authors: args.ciAuthors } : {}),
|
|
481
531
|
...(args.rerunFailed ? { rerunFailed: true } : {}),
|
|
@@ -488,10 +538,13 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
|
|
|
488
538
|
...(args.prBuild ? { build: true } : {}),
|
|
489
539
|
...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
|
|
490
540
|
},
|
|
541
|
+
...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
|
|
491
542
|
...(args.image ? { image: args.image } : {}),
|
|
492
543
|
...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
|
|
493
544
|
...(args.ciChant ? { chant: args.ciChant } : {}),
|
|
494
545
|
...(args.passEnv?.length ? { passEnv: args.passEnv } : {}),
|
|
546
|
+
...(status ? { status } : {}),
|
|
547
|
+
...(secrets ? { secrets } : {}),
|
|
495
548
|
...(runnerKey !== undefined ? { runnerKey, runnerKeyFromEnv: !fromFlag } : {}),
|
|
496
549
|
...(args.eventsJson ? { runEvents: (line: string) => void process.stdout.write(line) } : {}),
|
|
497
550
|
hostEnv,
|
|
@@ -44,6 +44,7 @@ import { PLANTABLE_REASON_CODES } from "./box-factory";
|
|
|
44
44
|
import { BOX_FACTORY_ERROR_CODES } from "./box-factory-write";
|
|
45
45
|
import { BOX_LISTING_ERROR_CODES } from "./box-listing";
|
|
46
46
|
import { DECLARATION_WRITE_ERROR_CODES } from "./declaration-write";
|
|
47
|
+
import { TRUST_WRITE_ERROR_CODES } from "./trust/trust-write";
|
|
47
48
|
import { BOX_PUBLISH_ERROR_CODES } from "./box-publish";
|
|
48
49
|
import { WRITE_LOCK_ERROR_CODES } from "./write-lock-cli";
|
|
49
50
|
import { WIP_ERROR_CODES, WIP_WRITE_ERROR_CODES } from "./wip";
|
|
@@ -115,6 +116,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
|
|
|
115
116
|
PLANTABLE_REASON_CODES,
|
|
116
117
|
BOX_LISTING_ERROR_CODES,
|
|
117
118
|
DECLARATION_WRITE_ERROR_CODES,
|
|
119
|
+
TRUST_WRITE_ERROR_CODES,
|
|
118
120
|
BOX_FACTORY_ERROR_CODES,
|
|
119
121
|
BOX_PUBLISH_ERROR_CODES,
|
|
120
122
|
WRITE_LOCK_ERROR_CODES,
|
|
@@ -271,6 +271,11 @@ export const REASONS = {
|
|
|
271
271
|
// A declaration's members and hosts written through chant (member add|remove, host set, #3596): why the write was refused. Nothing is written.
|
|
272
272
|
"member-exists": "member add names a member the declaration already has, with an entry other than the one given.",
|
|
273
273
|
"member-unknown": "member remove names a member the declaration does not declare.",
|
|
274
|
+
// A runner entry of .chant/trust.json written through chant (trust runner add|remove, #3510): why the write was refused. Nothing is written.
|
|
275
|
+
"trust-config-invalid": ".chant/trust.json in the working tree is not JSON or does not match its schema, or the write would make it so.",
|
|
276
|
+
"trust-conflict": "--expect named a digest .chant/trust.json no longer has: another write changed it after the caller read it. The refusal names the digest it has now.",
|
|
277
|
+
"runner-exists": "trust runner add names a principal .chant/trust.json already lists with another key or class, or gives a key it lists for another principal.",
|
|
278
|
+
"runner-unknown": "trust runner remove names a principal .chant/trust.json lists no runner for.",
|
|
274
279
|
// A box's factory written through chant (box factory set, #3600): why the write was refused. Nothing is written.
|
|
275
280
|
"factory-member-unknown": "box factory set names a member the declaration does not declare.",
|
|
276
281
|
"factory-box-missing": "box factory set names a member whose entry declares no box block, so it has no factory.",
|
|
@@ -314,8 +319,8 @@ export const REASONS = {
|
|
|
314
319
|
"evidence-payload-type": "The envelope's payload type is not application/vnd.in-toto+json.",
|
|
315
320
|
"evidence-statement-invalid": "The payload is not an in-toto Statement v1 with chant's runner-evidence predicate, or has a field the predicate does not define.",
|
|
316
321
|
"evidence-runner-mismatch": "The statement names a runner other than the one whose key signed it.",
|
|
317
|
-
"runner-key-invalid": "The key given to evidence sign or runs sign is not an Ed25519 private key in PEM.",
|
|
318
|
-
"runner-key-is-signer": "The key given to evidence sign
|
|
322
|
+
"runner-key-invalid": "The key given to evidence sign or runs sign is not an Ed25519 private key in PEM, or the key given to trust runner add is not an ssh-ed25519 public key or an Ed25519 key in PEM.",
|
|
323
|
+
"runner-key-is-signer": "The key given to evidence sign, runs sign or trust runner add is a person's key in the signers file, or trust runner add names a signer. Evidence and run statements are signed by a service or CI identity.",
|
|
319
324
|
"runner-key-unlisted": "The policy at base lists no runner with the key given to evidence sign or runs sign.",
|
|
320
325
|
// The lineage lock (check).
|
|
321
326
|
"lock-invalid": "The lineage lock can't be read.",
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3510: a runner entry of `.chant/trust.json` written through chant, with
|
|
3
|
+
* `--by` and `--expect` as the other working-tree writes take them, and read
|
|
4
|
+
* back by the readers that use runners: `readTrustPolicy`, `ciTagSigner` and
|
|
5
|
+
* `ci tick --source runs`.
|
|
6
|
+
*
|
|
7
|
+
* Each test works in its own temp repository (a bare origin where it pushes),
|
|
8
|
+
* and every key is a throwaway one. Nothing is written in process.cwd().
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
16
|
+
import { parseArgs } from "../../cli/parse-args";
|
|
17
|
+
import { cleanScratch, contract, declaration, git, scratchDir, writeFiles } from "../__fixtures__/contract-repo";
|
|
18
|
+
import { ciTagSigner, ciTick, opRunSigner } from "../ci-green";
|
|
19
|
+
import { workingTreeSource } from "../record-source";
|
|
20
|
+
import schema from "../trust-write.schema.json";
|
|
21
|
+
import { runOpLocally } from "../../op/local-executor";
|
|
22
|
+
import type { ActivityFn } from "../../op/activity-registry";
|
|
23
|
+
import { sshFromEd25519 } from "./dsse";
|
|
24
|
+
import { readTrustPolicy } from "./policy";
|
|
25
|
+
import { trustDigest, trustWrite, type TrustWriteDocument, type TrustWriteRequest } from "./trust-write";
|
|
26
|
+
|
|
27
|
+
afterAll(cleanScratch);
|
|
28
|
+
|
|
29
|
+
const { expectValid } = contract(schema);
|
|
30
|
+
|
|
31
|
+
function runnerKey(): { pem: string; ssh: string } {
|
|
32
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
33
|
+
const jwk = createPublicKey(privateKey).export({ format: "jwk" }) as { x: string };
|
|
34
|
+
return { pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), ssh: sshFromEd25519(Buffer.from(jwk.x, "base64url")) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function emptyRepo(files: Record<string, string> = {}): string {
|
|
38
|
+
const root = scratchDir("chant-trust-write-");
|
|
39
|
+
git(root, "init", "-q");
|
|
40
|
+
writeFiles(root, files);
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function run(root: string, req: Omit<TrustWriteRequest, "cwd">): TrustWriteDocument {
|
|
45
|
+
const doc = trustWrite({ ...req, cwd: root });
|
|
46
|
+
expectValid(doc);
|
|
47
|
+
return doc;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const code = (doc: TrustWriteDocument) => ("error" in doc ? doc.error.code : null);
|
|
51
|
+
const trustOf = (root: string) => JSON.parse(readFileSync(join(root, ".chant/trust.json"), "utf-8")) as Record<string, unknown>;
|
|
52
|
+
const ok = (doc: TrustWriteDocument) => {
|
|
53
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
54
|
+
return doc;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("trust runner add and remove (#3510)", () => {
|
|
58
|
+
test("add writes the entry and the runner role, remove takes both away, and a stale --expect is refused", () => {
|
|
59
|
+
const root = emptyRepo();
|
|
60
|
+
const bot = runnerKey();
|
|
61
|
+
writeFiles(root, { "keys/bot.pub": `${bot.ssh} bot@host\n` });
|
|
62
|
+
|
|
63
|
+
const added = ok(run(root, { action: "runner add", name: "ci:acme/app", key: "keys/bot.pub", by: "github:alex" }));
|
|
64
|
+
expect(added).toMatchObject({ action: "runner add", changed: true, previous: null, entry: { principal: "ci:acme/app", class: "runner", key: bot.ssh }, runnerRole: true, paths: [".chant/trust.json"] });
|
|
65
|
+
expect(added.previousDigest).toBe(trustDigest(null));
|
|
66
|
+
expect(added.digest).toBe(trustDigest(readFileSync(join(root, ".chant/trust.json"), "utf-8")));
|
|
67
|
+
expect(trustOf(root)).toEqual({ schema: 1, runners: [{ principal: "ci:acme/app", class: "runner", key: bot.ssh }], roles: { runner: ["ci:acme/app"] } });
|
|
68
|
+
|
|
69
|
+
// The same entry again changes nothing, and says so.
|
|
70
|
+
const again = ok(run(root, { action: "runner add", name: "ci:acme/app", key: bot.ssh, expect: added.digest }));
|
|
71
|
+
expect(again).toMatchObject({ changed: false, paths: [], digest: added.digest });
|
|
72
|
+
|
|
73
|
+
// A second runner, from a PEM private key, as a lobby holds it.
|
|
74
|
+
const lobby = runnerKey();
|
|
75
|
+
writeFileSync(join(root, "lobby.pem"), lobby.pem);
|
|
76
|
+
const second = ok(run(root, { action: "runner add", name: "studio:lobby", key: "lobby.pem", runnerClass: "service", expect: added.digest }));
|
|
77
|
+
expect(second).toMatchObject({ entry: { principal: "studio:lobby", class: "service", key: lobby.ssh }, runnerRole: false });
|
|
78
|
+
|
|
79
|
+
// The policy reads both, with the role for the runner only.
|
|
80
|
+
const policy = readTrustPolicy(workingTreeSource(root), null);
|
|
81
|
+
expect(policy.problems).toEqual([]);
|
|
82
|
+
expect(policy.runners.map((r) => r.principal)).toEqual(["ci:acme/app", "studio:lobby"]);
|
|
83
|
+
expect(policy.roles.runner).toEqual(["ci:acme/app"]);
|
|
84
|
+
|
|
85
|
+
const removed = ok(run(root, { action: "runner remove", name: "ci:acme/app", by: "github:alex", expect: second.digest }));
|
|
86
|
+
expect(removed).toMatchObject({ changed: true, previous: { principal: "ci:acme/app" }, entry: null, runnerRole: false });
|
|
87
|
+
expect(trustOf(root)).toEqual({ schema: 1, runners: [{ principal: "studio:lobby", class: "service", key: lobby.ssh }], roles: { runner: [] } });
|
|
88
|
+
|
|
89
|
+
// The digest the caller read is the one before the remove: refused, naming the last write.
|
|
90
|
+
const stale = run(root, { action: "runner remove", name: "studio:lobby", expect: second.digest });
|
|
91
|
+
expect(code(stale)).toBe("trust-conflict");
|
|
92
|
+
expect("conflict" in stale && stale.conflict).toMatchObject({ expected: second.digest, digest: removed.digest, lastWrite: { verb: "trust runner remove", by: "github:alex" } });
|
|
93
|
+
expect(trustOf(root).runners).toHaveLength(1);
|
|
94
|
+
|
|
95
|
+
expect(code(run(root, { action: "runner remove", name: "ci:acme/app" }))).toBe("runner-unknown");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("an existing file is edited in place, keeping its other keys and layout", () => {
|
|
99
|
+
const text = `{\n "schema": 1,\n "roles": {\n "admin": ["alex"]\n },\n "adopted": []\n}\n`;
|
|
100
|
+
const root = emptyRepo({ ".chant/trust.json": text });
|
|
101
|
+
const bot = runnerKey();
|
|
102
|
+
const doc = ok(run(root, { action: "runner add", name: "ci-bot", key: bot.ssh, expect: trustDigest(text) }));
|
|
103
|
+
const after = readFileSync(join(root, ".chant/trust.json"), "utf-8");
|
|
104
|
+
expect(after.startsWith(`{\n "schema": 1,\n "roles": {\n "admin": ["alex"],`)).toBe(true);
|
|
105
|
+
expect(JSON.parse(after)).toEqual({ schema: 1, roles: { admin: ["alex"], runner: ["ci-bot"] }, adopted: [], runners: [{ principal: "ci-bot", class: "runner", key: bot.ssh }] });
|
|
106
|
+
expect(doc.digest).toBe(trustDigest(after));
|
|
107
|
+
// --dry-run reports the same and writes nothing.
|
|
108
|
+
const dry = ok(run(root, { action: "runner remove", name: "ci-bot", dryRun: true }));
|
|
109
|
+
expect(dry.changed).toBe(true);
|
|
110
|
+
expect(readFileSync(join(root, ".chant/trust.json"), "utf-8")).toBe(after);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("refusals: a key that is not ssh-ed25519, a person's key, a taken key or principal, a broken file", () => {
|
|
114
|
+
const bot = runnerKey();
|
|
115
|
+
const person = runnerKey();
|
|
116
|
+
const root = emptyRepo({ ".chant/allowed_signers": `alex ${person.ssh}\n` });
|
|
117
|
+
expect(code(run(root, { action: "runner add", name: "x", key: "ssh-rsa AAAAB3NzaC1yc2E=" }))).toBe("runner-key-invalid");
|
|
118
|
+
expect(code(run(root, { action: "runner add", name: "x", key: "ssh-ed25519 AAAA" }))).toBe("runner-key-invalid");
|
|
119
|
+
expect(code(run(root, { action: "runner add", name: "x", key: "no-such-file.pub" }))).toBe("runner-key-invalid");
|
|
120
|
+
expect(code(run(root, { action: "runner add", name: "x", key: person.ssh }))).toBe("runner-key-is-signer");
|
|
121
|
+
expect(code(run(root, { action: "runner add", name: "alex", key: bot.ssh }))).toBe("runner-key-is-signer");
|
|
122
|
+
expect(code(run(root, { action: "runner add", name: "x" }))).toBe("write-usage-invalid");
|
|
123
|
+
expect(code(run(root, { action: "runner add", name: "x", key: bot.ssh, runnerClass: "person" }))).toBe("write-usage-invalid");
|
|
124
|
+
expect(code(run(root, { action: "runner add", name: "x", key: bot.ssh, expect: "abc" }))).toBe("write-usage-invalid");
|
|
125
|
+
expect(code(run(root, { action: "runner remove", name: "x", key: bot.ssh }))).toBe("write-usage-invalid");
|
|
126
|
+
|
|
127
|
+
ok(run(root, { action: "runner add", name: "ci-bot", key: bot.ssh }));
|
|
128
|
+
expect(code(run(root, { action: "runner add", name: "other", key: bot.ssh }))).toBe("runner-exists");
|
|
129
|
+
expect(code(run(root, { action: "runner add", name: "ci-bot", key: runnerKey().ssh }))).toBe("runner-exists");
|
|
130
|
+
expect(code(run(root, { action: "runner add", name: "ci-bot", key: bot.ssh, runnerClass: "service" }))).toBe("runner-exists");
|
|
131
|
+
|
|
132
|
+
writeFileSync(join(root, ".chant/trust.json"), `{ "schema": 2 }`);
|
|
133
|
+
expect(code(run(root, { action: "runner add", name: "y", key: runnerKey().ssh }))).toBe("trust-config-invalid");
|
|
134
|
+
expect(code(trustWrite({ cwd: scratchDir("chant-trust-nogit-"), action: "runner add", name: "y", key: bot.ssh }))).toBe("not-a-git-repository");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("the command line takes --class, --key, --by and --expect", () => {
|
|
138
|
+
const args = parseArgs(["workspace", "trust", "runner", "add", "ci-bot", "--key", "k.pub", "--class", "service", "--by", "github:alex", "--expect", "a".repeat(64)]);
|
|
139
|
+
expect(args).toMatchObject({ key: "k.pub", runnerClass: "service", by: "github:alex", expect: "a".repeat(64) });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("a runner added through the write is trusted by the readers (#3510)", () => {
|
|
144
|
+
test("ci tick --source runs tags a commit from a run signed by a runner the write listed", async () => {
|
|
145
|
+
const bot = runnerKey();
|
|
146
|
+
const origin = scratchDir("chant-trust-write-origin-");
|
|
147
|
+
git(origin, "init", "-q", "--bare");
|
|
148
|
+
const root = scratchDir("chant-trust-write-ci-");
|
|
149
|
+
git(root, "init", "-q");
|
|
150
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
151
|
+
git(root, "remote", "add", "origin", origin);
|
|
152
|
+
const green = { branch: "main", phases: { test: ["test"] }, require: ["test"], source: "runs" };
|
|
153
|
+
writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green } }), "app/a.txt": "a\n", "runner.pem": bot.pem });
|
|
154
|
+
writeFiles(root, { ".gitignore": "runner.pem\n" });
|
|
155
|
+
|
|
156
|
+
// Before the write, the key is refused.
|
|
157
|
+
git(root, "add", "-A");
|
|
158
|
+
git(root, "commit", "-q", "-m", "init");
|
|
159
|
+
expect(() => ciTagSigner(root, git(root, "rev-parse", "HEAD"), bot.pem)).toThrow(/lists no runner with this key/);
|
|
160
|
+
|
|
161
|
+
ok(run(root, { action: "runner add", name: "ci-bot", key: "runner.pem", by: "t" }));
|
|
162
|
+
git(root, "add", "-A");
|
|
163
|
+
git(root, "commit", "-q", "-m", "trust the ci-bot runner");
|
|
164
|
+
const head = git(root, "rev-parse", "HEAD");
|
|
165
|
+
expect(ciTagSigner(root, head, bot.pem).principal).toBe("ci-bot");
|
|
166
|
+
|
|
167
|
+
const pass = new Map<string, ActivityFn>([["step", async () => ({})]]);
|
|
168
|
+
const op = { name: "test", overview: "run test", labels: { Env: "ci" }, phases: [{ name: "Run", steps: [{ kind: "activity" as const, fn: "step", args: {} }] }] };
|
|
169
|
+
const { record } = await runOpLocally(op, pass, {}, undefined, { cwd: root, ledger: { cwd: root }, signRecord: opRunSigner(root, bot.pem) });
|
|
170
|
+
expect(record.gitSha).toBe(head);
|
|
171
|
+
git(root, "push", "-q", "origin", "main");
|
|
172
|
+
git(root, "push", "-q", "origin", "chant/lifecycle");
|
|
173
|
+
|
|
174
|
+
const tick = await ciTick({ cwd: root, runnerKey: bot.pem, now: new Date() });
|
|
175
|
+
expect(tick.made).toEqual([`ci/green/${head}`]);
|
|
176
|
+
expect(tick.signer).toBe("ci-bot");
|
|
177
|
+
expect(execFileSync("git", ["ls-remote", "--tags", origin], { encoding: "utf-8" })).toContain(`refs/tags/ci/green/${head}`);
|
|
178
|
+
});
|
|
179
|
+
});
|