@intentius/chant 0.124.5 → 0.124.6
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/gate-resume.d.ts +32 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +7 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-point-run.d.ts +1 -1
- package/dist/op/gate-point-run.d.ts.map +1 -1
- package/dist/op/gate-point.d.ts +6 -0
- package/dist/op/gate-point.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +2 -31
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-run-locator.d.ts +40 -0
- package/dist/op/gate-run-locator.d.ts.map +1 -0
- package/dist/point-decision.d.ts +63 -1
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +15 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.ts +84 -11
- package/src/cli/main.ts +4 -2
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/op/gate-point-ci.e2e.test.ts +39 -2
- package/src/op/gate-point-resume.e2e.test.ts +203 -0
- package/src/op/gate-point-run.ts +3 -0
- package/src/op/gate-point.ts +11 -0
- package/src/op/gate-resume.ts +5 -95
- package/src/op/gate-run-locator.ts +105 -0
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +97 -1
- package/src/workspace/points-cli.ts +7 -2
- package/src/workspace/records-cli.ts +38 -7
- package/src/workspace/records.schema.json +4 -0
|
@@ -82,6 +82,13 @@ export interface GateAnswerRef {
|
|
|
82
82
|
/** `table`, `model` or `quorum`. */
|
|
83
83
|
decider?: string;
|
|
84
84
|
answeredBy?: string[];
|
|
85
|
+
/**
|
|
86
|
+
* On a pending fact: the answers the gate passes on, when the gate names
|
|
87
|
+
* them (`point.pass`); absent means a noul point's yes. `chant run resume`
|
|
88
|
+
* reads it to tell an answer that passes the gate from one that does not,
|
|
89
|
+
* without the Op's config (#3835).
|
|
90
|
+
*/
|
|
91
|
+
pass?: Array<string | boolean>;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
/** One immutable gate-resolution record. */
|
|
@@ -20,6 +20,7 @@ import { join } from "node:path";
|
|
|
20
20
|
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
|
21
21
|
import { cleanScratch, git, workspace } from "../workspace/__fixtures__/decide-workspace";
|
|
22
22
|
import { runWorkspacePoints } from "../workspace/points-cli";
|
|
23
|
+
import { runWorkspaceRecords } from "../workspace/records-cli";
|
|
23
24
|
import type { CommandContext } from "../cli/registry";
|
|
24
25
|
import { ApplyOp } from "./composites/apply-op";
|
|
25
26
|
import { memoryGateLedgerPort } from "./gate";
|
|
@@ -71,7 +72,7 @@ function clone(name: string): string {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
/** `chant workspace points <argv>` through the command's handler, in `cwd`. Returns the document it prints. */
|
|
74
|
-
async function points(cwd: string, args: Record<string, unknown
|
|
75
|
+
async function points(cwd: string, args: Record<string, unknown>, handler = runWorkspacePoints): Promise<Record<string, unknown> & { error?: { code: string } }> {
|
|
75
76
|
const printed: string[] = [];
|
|
76
77
|
const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
|
|
77
78
|
printed.push(String(line));
|
|
@@ -79,7 +80,7 @@ async function points(cwd: string, args: Record<string, unknown>): Promise<Recor
|
|
|
79
80
|
const was = process.cwd();
|
|
80
81
|
process.chdir(cwd);
|
|
81
82
|
try {
|
|
82
|
-
await
|
|
83
|
+
await handler({ args, plugins: [], serializers: [] } as unknown as CommandContext);
|
|
83
84
|
} finally {
|
|
84
85
|
process.chdir(was);
|
|
85
86
|
log.mockRestore();
|
|
@@ -169,3 +170,39 @@ describe("a gate's question outlives the CI job that asked it (#3815)", () => {
|
|
|
169
170
|
expect(answersIn(tree)).toHaveLength(1);
|
|
170
171
|
});
|
|
171
172
|
});
|
|
173
|
+
|
|
174
|
+
describe("chant workspace records lists the answers held on chant/lifecycle (#3833)", () => {
|
|
175
|
+
type Rec = { id: string; path: string; state: string; data: { answer?: unknown }; ledger?: string | null; worktree?: string };
|
|
176
|
+
const answerRecords = (set: Record<string, unknown>): Rec[] => {
|
|
177
|
+
const kinds = set.kinds as { kind: { name: string }; records: Rec[] }[];
|
|
178
|
+
return kinds.find((k) => k.kind.name === "answer")!.records;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
test("an answer asked and answered on the ledger is listed with the id, state and answer points shows", async () => {
|
|
182
|
+
vi.stubEnv("CI", "");
|
|
183
|
+
const op = config("records-apply", { name: "prod-apply", store: "ledger" });
|
|
184
|
+
const first = await run(op, clone("records-job"));
|
|
185
|
+
expect(first.status).toBe("waiting");
|
|
186
|
+
const id = first.point!.id;
|
|
187
|
+
expect(await points(clone("records-person"), { extraPositional: "answer", extraPositional2: id, answer: "yes", bys: ["alice"] })).toMatchObject({ written: true });
|
|
188
|
+
|
|
189
|
+
// A clone that never held chant/lifecycle: records fetches it, as points answer does.
|
|
190
|
+
const reader = clone("records-reader");
|
|
191
|
+
const listed = answerRecords(await points(reader, { json: true }, runWorkspaceRecords)).find((r) => r.id === id);
|
|
192
|
+
expect(listed).toMatchObject({ id, state: "answered", data: { answer: true }, ledger: expect.stringContaining("chant/lifecycle:") });
|
|
193
|
+
expect(listed!.worktree).toBeUndefined();
|
|
194
|
+
|
|
195
|
+
const doc = (await points(reader, { json: true })) as { questions: { id: string; state: string; answer: unknown; ledger: string | null; path: string }[] };
|
|
196
|
+
const question = doc.questions.find((q) => q.id === id)!;
|
|
197
|
+
expect({ id: listed!.id, state: listed!.state, answer: listed!.data.answer, ledger: listed!.ledger, path: listed!.path }).toEqual({
|
|
198
|
+
id: question.id,
|
|
199
|
+
state: question.state,
|
|
200
|
+
answer: question.answer,
|
|
201
|
+
ledger: question.ledger,
|
|
202
|
+
path: question.path,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// The checkout doesn't hold it: --uncommitted leaves it out, and the text output says where it is.
|
|
206
|
+
expect(answerRecords(await points(reader, { json: true, uncommitted: true }, runWorkspaceRecords)).map((r) => r.id)).not.toContain(id);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3835: an answered decision point resumes the job waiting on it. A gate
|
|
3
|
+
* that asks a point writes its resolution only when the run asks again, so
|
|
4
|
+
* `chant run resume` used to see no approval and leave the job stopped. Now a
|
|
5
|
+
* pending fact that cites a question is resumable once the question has an
|
|
6
|
+
* answer the gate passes on, and `points answer --resume` resumes right after
|
|
7
|
+
* the answer.
|
|
8
|
+
*
|
|
9
|
+
* Clones of one bare remote stand for the CI job's checkout, the person's
|
|
10
|
+
* clone the answer comes from, the scheduled resume job's checkout and the
|
|
11
|
+
* resumed job's. The gate ledger is the real one on `chant/lifecycle`, so the
|
|
12
|
+
* pending fact, the question and its answer all travel through the remote.
|
|
13
|
+
* The forge is a stubbed `fetch` that answers as GitHub does for a failed run.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
21
|
+
import { cleanScratch, workspace } from "../workspace/__fixtures__/decide-workspace";
|
|
22
|
+
import { runWorkspacePoints } from "../workspace/points-cli";
|
|
23
|
+
import { runResumeCommand } from "../cli/handlers/gate-resume";
|
|
24
|
+
import type { CommandContext } from "../cli/registry";
|
|
25
|
+
import { readGateLedger } from "../lifecycle/gate-ledger";
|
|
26
|
+
import { pointGateResumable } from "../point-decision";
|
|
27
|
+
import { ApplyOp } from "./composites/apply-op";
|
|
28
|
+
import { gitGateLedgerPort } from "./gate";
|
|
29
|
+
import type { GatePoint } from "./gate-point";
|
|
30
|
+
import { runOpLocally } from "./local-executor";
|
|
31
|
+
import type { ActivityFn } from "./activity-registry";
|
|
32
|
+
import type { OpConfig } from "./types";
|
|
33
|
+
|
|
34
|
+
const PLAN = `jcs1-sha256:${"6".repeat(64)}`;
|
|
35
|
+
|
|
36
|
+
const POINTS = {
|
|
37
|
+
points: {
|
|
38
|
+
"prod-apply": {
|
|
39
|
+
title: "Apply this plan to prod",
|
|
40
|
+
question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
|
|
41
|
+
inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
|
|
42
|
+
deciders: [{ kind: "quorum", count: 1 }],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const GITHUB = { GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "acme/infra", GITHUB_RUN_ID: "42", GITHUB_RUN_ATTEMPT: "1", GITHUB_API_URL: "https://api.github.test" };
|
|
48
|
+
|
|
49
|
+
const applied: string[] = [];
|
|
50
|
+
const activities = new Map<string, ActivityFn>([
|
|
51
|
+
["chantBuild", async () => ({ ok: true })],
|
|
52
|
+
["lifecycleDiff", async () => ({ planDigest: PLAN, drifted: true })],
|
|
53
|
+
["nativeApply", async () => {
|
|
54
|
+
applied.push(PLAN);
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}],
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const config = (name: string, point: GatePoint): OpConfig =>
|
|
60
|
+
(ApplyOp({ name, env: "prod", target: "kubectl", gate: { gate: "approve-apply", point } }).op as unknown as { props: OpConfig }).props;
|
|
61
|
+
|
|
62
|
+
let home: string;
|
|
63
|
+
let remote: string;
|
|
64
|
+
let n = 0;
|
|
65
|
+
|
|
66
|
+
function clone(): string {
|
|
67
|
+
const dir = join(home, `clone-${++n}`);
|
|
68
|
+
execFileSync("git", ["clone", "-q", remote, dir], { stdio: "ignore" });
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A wave's run in `cwd`, on the real gate ledger. */
|
|
73
|
+
const run = (op: OpConfig, cwd: string) => runOpLocally(op, activities, {}, undefined, { gates: gitGateLedgerPort({ cwd }), cwd, now: new Date().toISOString() });
|
|
74
|
+
|
|
75
|
+
/** Run a command handler in `cwd`; returns what it printed on standard output, parsed, and its exit code. */
|
|
76
|
+
async function inDir<T>(cwd: string, handler: (ctx: CommandContext) => Promise<number>, args: Record<string, unknown>): Promise<{ code: number; doc: T }> {
|
|
77
|
+
const printed: string[] = [];
|
|
78
|
+
const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
|
|
79
|
+
printed.push(String(line));
|
|
80
|
+
});
|
|
81
|
+
const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
82
|
+
const was = process.cwd();
|
|
83
|
+
process.chdir(cwd);
|
|
84
|
+
try {
|
|
85
|
+
const code = await handler({ args, plugins: [], serializers: [] } as unknown as CommandContext);
|
|
86
|
+
return { code, doc: JSON.parse(printed.join("\n")) as T };
|
|
87
|
+
} finally {
|
|
88
|
+
process.chdir(was);
|
|
89
|
+
log.mockRestore();
|
|
90
|
+
err.mockRestore();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The forge: a GitHub run that ended in failure (the job exited 3 at the gate). Records each call. */
|
|
95
|
+
let calls: string[];
|
|
96
|
+
function stubForge(): void {
|
|
97
|
+
calls = [];
|
|
98
|
+
vi.stubGlobal("fetch", async (url: string, init: { method: string }) => {
|
|
99
|
+
calls.push(`${init.method} ${new URL(url).pathname}`);
|
|
100
|
+
const body = init.method === "GET" ? JSON.stringify({ status: "completed", conclusion: "failure", run_attempt: 1 }) : "";
|
|
101
|
+
return { ok: true, status: 200, text: async () => body };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type Report = { op: string; gate: string; status: string; message: string; approvedBy?: string };
|
|
106
|
+
|
|
107
|
+
/** The job waits at the gate in CI; returns the question's id. */
|
|
108
|
+
async function waitInCi(op: OpConfig): Promise<string> {
|
|
109
|
+
for (const [k, v] of Object.entries(GITHUB)) vi.stubEnv(k, v);
|
|
110
|
+
const first = await run(op, clone());
|
|
111
|
+
expect(first.status).toBe("waiting");
|
|
112
|
+
for (const k of Object.keys(GITHUB)) vi.stubEnv(k, "");
|
|
113
|
+
return first.point!.id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const answer = (id: string, value: string, extra: Record<string, unknown> = {}) =>
|
|
117
|
+
inDir<Record<string, unknown>>(clone(), runWorkspacePoints, { extraPositional: "answer", extraPositional2: id, answer: value, bys: ["alice"], ...extra });
|
|
118
|
+
|
|
119
|
+
beforeAll(() => {
|
|
120
|
+
home = realpathSync(mkdtempSync(join(tmpdir(), "chant-gate-point-resume-")));
|
|
121
|
+
const root = workspace(POINTS);
|
|
122
|
+
remote = join(home, "remote.git");
|
|
123
|
+
execFileSync("git", ["clone", "-q", "--bare", root, remote], { stdio: "ignore" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
vi.stubEnv("CI", "");
|
|
128
|
+
vi.stubEnv("GITHUB_TOKEN", "t");
|
|
129
|
+
stubForge();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
vi.unstubAllEnvs();
|
|
134
|
+
vi.unstubAllGlobals();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterAll(() => {
|
|
138
|
+
cleanScratch();
|
|
139
|
+
rmSync(home, { recursive: true, force: true });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("an answered decision point resumes the wave waiting on it (#3835)", () => {
|
|
143
|
+
test("the wave waits on its point; the point is answered; chant run resume resumes it, and the resumed run passes", async () => {
|
|
144
|
+
const op = config("resume-apply", { name: "prod-apply", store: "ledger" });
|
|
145
|
+
const id = await waitInCi(op);
|
|
146
|
+
|
|
147
|
+
// Before the answer, nothing is resumed.
|
|
148
|
+
const before = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "resume-apply", json: true });
|
|
149
|
+
expect(before.doc.reports).toEqual([expect.objectContaining({ gate: "approve-apply", status: "not-approved", message: expect.stringContaining("not answered yet") })]);
|
|
150
|
+
expect(calls).toEqual([]);
|
|
151
|
+
|
|
152
|
+
expect((await answer(id, "yes")).doc).toMatchObject({ written: true, question: { state: "answered", answer: true } });
|
|
153
|
+
|
|
154
|
+
// The scheduled resume job, in its own checkout.
|
|
155
|
+
const resumed = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "resume-apply", json: true });
|
|
156
|
+
expect(resumed.code).toBe(0);
|
|
157
|
+
expect(resumed.doc.reports).toEqual([expect.objectContaining({ gate: "approve-apply", status: "resumed", approvedBy: "alice" })]);
|
|
158
|
+
expect(calls).toEqual(["GET /repos/acme/infra/actions/runs/42", "POST /repos/acme/infra/actions/runs/42/rerun-failed-jobs"]);
|
|
159
|
+
|
|
160
|
+
// The resumed job asks again, finds the answer, writes the resolution that cites it, and applies.
|
|
161
|
+
const job = clone();
|
|
162
|
+
const next = await run(op, job);
|
|
163
|
+
expect(next.status).toBe("ok");
|
|
164
|
+
expect(applied).toContain(PLAN);
|
|
165
|
+
const ledger = await readGateLedger("resume-apply", { cwd: job });
|
|
166
|
+
expect(ledger.resolutions).toEqual([expect.objectContaining({ gate: "approve-apply", resolvedBy: "alice", answer: expect.objectContaining({ id, answer: true }) })]);
|
|
167
|
+
}, 120_000);
|
|
168
|
+
|
|
169
|
+
test("an answer that does not pass the gate resumes nothing", async () => {
|
|
170
|
+
const op = config("refuse-apply", { name: "prod-apply", store: "ledger" });
|
|
171
|
+
const id = await waitInCi(op);
|
|
172
|
+
await answer(id, "no");
|
|
173
|
+
|
|
174
|
+
const resumed = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "refuse-apply", json: true });
|
|
175
|
+
expect(resumed.code).toBe(0);
|
|
176
|
+
expect(resumed.doc.reports).toEqual([expect.objectContaining({ status: "not-approved", message: expect.stringContaining("passes only on true") })]);
|
|
177
|
+
expect(calls).toEqual([]);
|
|
178
|
+
}, 120_000);
|
|
179
|
+
|
|
180
|
+
test("a gate's own pass list is on the pending fact: an answer it names resumes, others don't", async () => {
|
|
181
|
+
const op = config("pass-apply", { name: "prod-apply", store: "ledger", pass: [false] });
|
|
182
|
+
const id = await waitInCi(op);
|
|
183
|
+
const { pending } = await gitGateLedgerPort({ cwd: clone() }).read("pass-apply");
|
|
184
|
+
expect(pending[0]!.answer).toMatchObject({ id, pass: [false] });
|
|
185
|
+
expect(pending[0]!.resume).toMatchObject({ forge: "github", run: "42" });
|
|
186
|
+
|
|
187
|
+
const answered = { id, state: "answered" as const, answeredBy: ["alice"], answeredOn: null };
|
|
188
|
+
expect(pointGateResumable(pending[0]!, { ...answered, answer: false })).toMatchObject({ resumable: true, answeredBy: "alice" });
|
|
189
|
+
expect(pointGateResumable(pending[0]!, { ...answered, answer: true })).toMatchObject({ resumable: false, why: "refused" });
|
|
190
|
+
expect(pointGateResumable(pending[0]!, { ...answered, state: "escalated", answer: null })).toMatchObject({ resumable: false, why: "open" });
|
|
191
|
+
expect(pointGateResumable(pending[0]!, undefined)).toMatchObject({ resumable: false, why: "unknown" });
|
|
192
|
+
}, 120_000);
|
|
193
|
+
|
|
194
|
+
test("points answer --resume resumes the job waiting at the gate the question names", async () => {
|
|
195
|
+
const op = config("answer-resume-apply", { name: "prod-apply", store: "ledger" });
|
|
196
|
+
const id = await waitInCi(op);
|
|
197
|
+
|
|
198
|
+
const answered = await answer(id, "yes", { resumeRun: true });
|
|
199
|
+
expect(answered.code).toBe(0);
|
|
200
|
+
expect(answered.doc).toMatchObject({ written: true, question: { state: "answered", subject: "gate:answer-resume-apply/approve-apply" } });
|
|
201
|
+
expect(calls).toEqual(["GET /repos/acme/infra/actions/runs/42", "POST /repos/acme/infra/actions/runs/42/rerun-failed-jobs"]);
|
|
202
|
+
}, 120_000);
|
|
203
|
+
});
|
package/src/op/gate-point-run.ts
CHANGED
|
@@ -32,7 +32,10 @@ import type { GatePointAsker } from "../point-decision";
|
|
|
32
32
|
// The decision itself bundles without the workspace (#3816): `@intentius/chant/point-decision`.
|
|
33
33
|
export {
|
|
34
34
|
evaluatePointGate,
|
|
35
|
+
pointGateResumable,
|
|
35
36
|
type GatePointAsker,
|
|
37
|
+
type PointQuestionState,
|
|
38
|
+
type PointResumeCheck,
|
|
36
39
|
type GatePointQuestion,
|
|
37
40
|
type GatePointRequest,
|
|
38
41
|
type PointGateCheck,
|
package/src/op/gate-point.ts
CHANGED
|
@@ -141,3 +141,14 @@ export function gatePointInputs(point: string, declared: readonly string[], fact
|
|
|
141
141
|
export function gateSubject(op: string, gate: string, env?: string | null): string {
|
|
142
142
|
return `gate:${op}/${gate}${env ? `@${env}` : ""}`;
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/** The gate a {@link gateSubject} names, or undefined for a subject that names none (#3835). */
|
|
146
|
+
export function parseGateSubject(subject: string | null | undefined): { op: string; gate: string; env?: string } | undefined {
|
|
147
|
+
if (!subject || !subject.startsWith("gate:")) return undefined;
|
|
148
|
+
const rest = subject.slice("gate:".length);
|
|
149
|
+
const at = rest.lastIndexOf("@");
|
|
150
|
+
const named = at > 0 ? rest.slice(0, at) : rest;
|
|
151
|
+
const slash = named.indexOf("/");
|
|
152
|
+
if (slash <= 0 || slash === named.length - 1) return undefined;
|
|
153
|
+
return { op: named.slice(0, slash), gate: named.slice(slash + 1), ...(at > 0 ? { env: rest.slice(at + 1) } : {}) };
|
|
154
|
+
}
|
package/src/op/gate-resume.ts
CHANGED
|
@@ -36,102 +36,10 @@ import { ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
|
|
|
36
36
|
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
37
37
|
import { latestResolutionForPlan } from "../lifecycle/gate-ledger";
|
|
38
38
|
import { humanQuorumSince, tallyGateApprovals } from "./gate";
|
|
39
|
+
import type { GateRunLocator } from "./gate-run-locator";
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
export
|
|
42
|
-
forge: ForgeKind;
|
|
43
|
-
/** The forge's API base: `$GITHUB_API_URL` (GitHub and Forgejo) or `$CI_API_V4_URL` (GitLab). */
|
|
44
|
-
api: string;
|
|
45
|
-
/** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
|
|
46
|
-
repo: string;
|
|
47
|
-
/** The run: GitHub's and Forgejo's run id, GitLab's pipeline id. */
|
|
48
|
-
run: string;
|
|
49
|
-
/** GitHub: the run attempt that waited. A later attempt means it was already re-run. */
|
|
50
|
-
attempt?: number;
|
|
51
|
-
/** GitLab: the job that waited. */
|
|
52
|
-
job?: string;
|
|
53
|
-
/** GitLab: that job's name, to find a retry of it. */
|
|
54
|
-
jobName?: string;
|
|
55
|
-
/** Forgejo: the workflow file the run came from, which a dispatch names. */
|
|
56
|
-
workflow?: string;
|
|
57
|
-
/** Forgejo: the branch a dispatch runs on. */
|
|
58
|
-
ref?: string;
|
|
59
|
-
/** The run's page, for a person. */
|
|
60
|
-
url?: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The locator of the CI job this process runs in, or undefined outside CI or
|
|
67
|
-
* when the environment lacks a part a resume needs. Forgejo Actions sets the
|
|
68
|
-
* `GITHUB_*` variables too, and is told apart by `FORGEJO_ACTIONS` or
|
|
69
|
-
* `GITEA_ACTIONS`.
|
|
70
|
-
*/
|
|
71
|
-
export function resolveGateRunLocator(env: Record<string, string | undefined> = process.env): GateRunLocator | undefined {
|
|
72
|
-
if (env.GITLAB_CI === "true") {
|
|
73
|
-
if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !env.CI_PIPELINE_ID || !env.CI_JOB_ID) return undefined;
|
|
74
|
-
return {
|
|
75
|
-
forge: "gitlab",
|
|
76
|
-
api: trimSlash(env.CI_API_V4_URL),
|
|
77
|
-
repo: env.CI_PROJECT_ID,
|
|
78
|
-
run: env.CI_PIPELINE_ID,
|
|
79
|
-
job: env.CI_JOB_ID,
|
|
80
|
-
...(env.CI_JOB_NAME ? { jobName: env.CI_JOB_NAME } : {}),
|
|
81
|
-
...(env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_REPOSITORY || !env.GITHUB_RUN_ID) return undefined;
|
|
85
|
-
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
|
|
86
|
-
const server = env.GITHUB_SERVER_URL ? trimSlash(env.GITHUB_SERVER_URL) : undefined;
|
|
87
|
-
const url = server ? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined;
|
|
88
|
-
if (forgejo) {
|
|
89
|
-
const workflow = forgejoWorkflowFile(env);
|
|
90
|
-
const api = env.GITHUB_API_URL ?? (server ? `${server}/api/v1` : undefined);
|
|
91
|
-
if (!api || !workflow || !env.GITHUB_REF_NAME) return undefined;
|
|
92
|
-
return {
|
|
93
|
-
forge: "forgejo",
|
|
94
|
-
api: trimSlash(api),
|
|
95
|
-
repo: env.GITHUB_REPOSITORY,
|
|
96
|
-
run: env.GITHUB_RUN_ID,
|
|
97
|
-
workflow,
|
|
98
|
-
ref: env.GITHUB_REF_NAME,
|
|
99
|
-
...(url ? { url } : {}),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
const attempt = Number(env.GITHUB_RUN_ATTEMPT);
|
|
103
|
-
return {
|
|
104
|
-
forge: "github",
|
|
105
|
-
api: trimSlash(env.GITHUB_API_URL ?? "https://api.github.com"),
|
|
106
|
-
repo: env.GITHUB_REPOSITORY,
|
|
107
|
-
run: env.GITHUB_RUN_ID,
|
|
108
|
-
...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}),
|
|
109
|
-
...(url ? { url } : {}),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* The workflow file a Forgejo run came from, by its base name. Forgejo names
|
|
115
|
-
* a workflow by its file (`GITHUB_WORKFLOW`, `deploy.yml`); `GITHUB_WORKFLOW_REF`
|
|
116
|
-
* (`owner/repo/.forgejo/workflows/deploy.yml@refs/heads/main`) says it too.
|
|
117
|
-
*/
|
|
118
|
-
function forgejoWorkflowFile(env: Record<string, string | undefined>): string | undefined {
|
|
119
|
-
const fromRef = env.GITHUB_WORKFLOW_REF?.split("@")[0]?.split("/").pop();
|
|
120
|
-
if (fromRef && /\.ya?ml$/.test(fromRef)) return fromRef;
|
|
121
|
-
const name = env.GITHUB_WORKFLOW;
|
|
122
|
-
return name && /\.ya?ml$/.test(name) ? name.split("/").pop() : undefined;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Whether `value` is a {@link GateRunLocator} a resume can use. */
|
|
126
|
-
export function isGateRunLocator(value: unknown): value is GateRunLocator {
|
|
127
|
-
if (!value || typeof value !== "object") return false;
|
|
128
|
-
const v = value as Record<string, unknown>;
|
|
129
|
-
if (typeof v.api !== "string" || typeof v.repo !== "string" || typeof v.run !== "string") return false;
|
|
130
|
-
if (v.forge === "github") return true;
|
|
131
|
-
if (v.forge === "gitlab") return typeof v.job === "string";
|
|
132
|
-
if (v.forge === "forgejo") return typeof v.workflow === "string" && typeof v.ref === "string";
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
41
|
+
// The locator is read where a pending fact is recorded, which `@intentius/chant/point-decision` does too, so it lives on its own (#3835).
|
|
42
|
+
export { isGateRunLocator, resolveGateRunLocator, type GateRunLocator } from "./gate-run-locator";
|
|
135
43
|
|
|
136
44
|
/** What {@link resumeGateRun} did. */
|
|
137
45
|
export type GateResumeOutcome =
|
|
@@ -145,6 +53,8 @@ export interface ResumeGateRunOptions {
|
|
|
145
53
|
fetch?: ForgeFetch;
|
|
146
54
|
}
|
|
147
55
|
|
|
56
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
57
|
+
|
|
148
58
|
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
149
59
|
|
|
150
60
|
function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a CI job that waits at a gate runs, and what its forge needs to start
|
|
3
|
+
* it again (#3683). Read from the job's environment when a pending fact is
|
|
4
|
+
* recorded; `./gate-resume.ts` uses it to resume the job. It imports nothing
|
|
5
|
+
* at run time, so `@intentius/chant/point-decision` records it on the pending
|
|
6
|
+
* fact of a gate that asks a decision point too (#3835).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ForgeKind } from "../pr-forge";
|
|
10
|
+
|
|
11
|
+
/** Where the job that recorded a pending fact runs, and what its forge needs to start it again. */
|
|
12
|
+
export interface GateRunLocator {
|
|
13
|
+
forge: ForgeKind;
|
|
14
|
+
/** The forge's API base: `$GITHUB_API_URL` (GitHub and Forgejo) or `$CI_API_V4_URL` (GitLab). */
|
|
15
|
+
api: string;
|
|
16
|
+
/** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
|
|
17
|
+
repo: string;
|
|
18
|
+
/** The run: GitHub's and Forgejo's run id, GitLab's pipeline id. */
|
|
19
|
+
run: string;
|
|
20
|
+
/** GitHub: the run attempt that waited. A later attempt means it was already re-run. */
|
|
21
|
+
attempt?: number;
|
|
22
|
+
/** GitLab: the job that waited. */
|
|
23
|
+
job?: string;
|
|
24
|
+
/** GitLab: that job's name, to find a retry of it. */
|
|
25
|
+
jobName?: string;
|
|
26
|
+
/** Forgejo: the workflow file the run came from, which a dispatch names. */
|
|
27
|
+
workflow?: string;
|
|
28
|
+
/** Forgejo: the branch a dispatch runs on. */
|
|
29
|
+
ref?: string;
|
|
30
|
+
/** The run's page, for a person. */
|
|
31
|
+
url?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The locator of the CI job this process runs in, or undefined outside CI or
|
|
38
|
+
* when the environment lacks a part a resume needs. Forgejo Actions sets the
|
|
39
|
+
* `GITHUB_*` variables too, and is told apart by `FORGEJO_ACTIONS` or
|
|
40
|
+
* `GITEA_ACTIONS`.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveGateRunLocator(env: Record<string, string | undefined> = process.env): GateRunLocator | undefined {
|
|
43
|
+
if (env.GITLAB_CI === "true") {
|
|
44
|
+
if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !env.CI_PIPELINE_ID || !env.CI_JOB_ID) return undefined;
|
|
45
|
+
return {
|
|
46
|
+
forge: "gitlab",
|
|
47
|
+
api: trimSlash(env.CI_API_V4_URL),
|
|
48
|
+
repo: env.CI_PROJECT_ID,
|
|
49
|
+
run: env.CI_PIPELINE_ID,
|
|
50
|
+
job: env.CI_JOB_ID,
|
|
51
|
+
...(env.CI_JOB_NAME ? { jobName: env.CI_JOB_NAME } : {}),
|
|
52
|
+
...(env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_REPOSITORY || !env.GITHUB_RUN_ID) return undefined;
|
|
56
|
+
const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
|
|
57
|
+
const server = env.GITHUB_SERVER_URL ? trimSlash(env.GITHUB_SERVER_URL) : undefined;
|
|
58
|
+
const url = server ? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined;
|
|
59
|
+
if (forgejo) {
|
|
60
|
+
const workflow = forgejoWorkflowFile(env);
|
|
61
|
+
const api = env.GITHUB_API_URL ?? (server ? `${server}/api/v1` : undefined);
|
|
62
|
+
if (!api || !workflow || !env.GITHUB_REF_NAME) return undefined;
|
|
63
|
+
return {
|
|
64
|
+
forge: "forgejo",
|
|
65
|
+
api: trimSlash(api),
|
|
66
|
+
repo: env.GITHUB_REPOSITORY,
|
|
67
|
+
run: env.GITHUB_RUN_ID,
|
|
68
|
+
workflow,
|
|
69
|
+
ref: env.GITHUB_REF_NAME,
|
|
70
|
+
...(url ? { url } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const attempt = Number(env.GITHUB_RUN_ATTEMPT);
|
|
74
|
+
return {
|
|
75
|
+
forge: "github",
|
|
76
|
+
api: trimSlash(env.GITHUB_API_URL ?? "https://api.github.com"),
|
|
77
|
+
repo: env.GITHUB_REPOSITORY,
|
|
78
|
+
run: env.GITHUB_RUN_ID,
|
|
79
|
+
...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}),
|
|
80
|
+
...(url ? { url } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The workflow file a Forgejo run came from, by its base name. Forgejo names
|
|
86
|
+
* a workflow by its file (`GITHUB_WORKFLOW`, `deploy.yml`); `GITHUB_WORKFLOW_REF`
|
|
87
|
+
* (`owner/repo/.forgejo/workflows/deploy.yml@refs/heads/main`) says it too.
|
|
88
|
+
*/
|
|
89
|
+
function forgejoWorkflowFile(env: Record<string, string | undefined>): string | undefined {
|
|
90
|
+
const fromRef = env.GITHUB_WORKFLOW_REF?.split("@")[0]?.split("/").pop();
|
|
91
|
+
if (fromRef && /\.ya?ml$/.test(fromRef)) return fromRef;
|
|
92
|
+
const name = env.GITHUB_WORKFLOW;
|
|
93
|
+
return name && /\.ya?ml$/.test(name) ? name.split("/").pop() : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether `value` is a {@link GateRunLocator} a resume can use. */
|
|
97
|
+
export function isGateRunLocator(value: unknown): value is GateRunLocator {
|
|
98
|
+
if (!value || typeof value !== "object") return false;
|
|
99
|
+
const v = value as Record<string, unknown>;
|
|
100
|
+
if (typeof v.api !== "string" || typeof v.repo !== "string" || typeof v.run !== "string") return false;
|
|
101
|
+
if (v.forge === "github") return true;
|
|
102
|
+
if (v.forge === "gitlab") return typeof v.job === "string";
|
|
103
|
+
if (v.forge === "forgejo") return typeof v.workflow === "string" && typeof v.ref === "string";
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
@@ -54,6 +54,7 @@ describe("the point-decision bundle", () => {
|
|
|
54
54
|
"packages/core/src/lifecycle/plan-digest.ts",
|
|
55
55
|
"packages/core/src/op/duration.ts",
|
|
56
56
|
"packages/core/src/op/gate-point.ts",
|
|
57
|
+
"packages/core/src/op/gate-run-locator.ts",
|
|
57
58
|
"packages/core/src/point-decision.ts",
|
|
58
59
|
]);
|
|
59
60
|
const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
|