@intentius/chant 0.115.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/gate-resume.d.ts +44 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -0
- package/dist/cli/handlers/operator.d.ts +1 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run-generate.d.ts.map +1 -1
- package/dist/cli/handlers/run-wave.d.ts +7 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +11 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lexicon.d.ts +22 -0
- package/dist/lexicon.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-resume.d.ts +104 -0
- package/dist/op/gate-resume.d.ts.map +1 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts +15 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +115 -0
- package/dist/op/op-waves-run.d.ts.map +1 -0
- package/dist/op/op-waves.d.ts +158 -0
- package/dist/op/op-waves.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.test.ts +51 -0
- package/src/cli/handlers/gate-resume.ts +166 -0
- package/src/cli/handlers/operator.ts +8 -2
- package/src/cli/handlers/run-generate.ts +51 -1
- package/src/cli/handlers/run-wave.ts +43 -0
- package/src/cli/main.ts +28 -2
- package/src/cli/registry.ts +11 -1
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/op/gate-resume.e2e.test.ts +135 -0
- package/src/op/gate-resume.test.ts +177 -0
- package/src/op/gate-resume.ts +267 -0
- package/src/op/gate.ts +4 -0
- package/src/op/generate-pipeline.ts +49 -0
- package/src/op/index.ts +13 -2
- package/src/op/op-waves-run.ts +325 -0
- package/src/op/op-waves.test.ts +95 -0
- package/src/op/op-waves.ts +250 -0
package/src/cli/main.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { runGraph } from "./handlers/graph";
|
|
|
36
36
|
import { runExplain } from "./handlers/explain";
|
|
37
37
|
import { runSearch } from "./handlers/search";
|
|
38
38
|
import { runOp, runOpList, runOpStatus, runOpApprove, runOpSignalRenamed, runOpCancel, runOpLog } from "./handlers/run";
|
|
39
|
+
import { runOpWaveCommand } from "./handlers/run-wave";
|
|
39
40
|
import { runOperator, runOperatorStatus, runOperatorLog, runApprove } from "./handlers/operator";
|
|
40
41
|
import { runEmulator } from "./handlers/emulator";
|
|
41
42
|
import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
|
|
@@ -106,6 +107,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
106
107
|
"--allow-code",
|
|
107
108
|
"--root-only",
|
|
108
109
|
"--generated",
|
|
110
|
+
"--decide",
|
|
109
111
|
]);
|
|
110
112
|
|
|
111
113
|
/**
|
|
@@ -355,6 +357,13 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
355
357
|
const wave = Number(raw);
|
|
356
358
|
if (!Number.isInteger(wave) || wave < 1) throw new Error(`--wave needs a wave number from 1: got "${raw ?? ""}"`);
|
|
357
359
|
result.wave = wave;
|
|
360
|
+
} else if (arg === "--decide") {
|
|
361
|
+
result.decide = true;
|
|
362
|
+
} else if (arg === "--share") {
|
|
363
|
+
const raw = args[++i];
|
|
364
|
+
const share = Number(raw);
|
|
365
|
+
if (!Number.isInteger(share) || share < 1) throw new Error(`--share needs a share number from 1: got "${raw ?? ""}"`);
|
|
366
|
+
result.share = share;
|
|
358
367
|
} else if (arg === "--canary") {
|
|
359
368
|
const raw = args[++i];
|
|
360
369
|
if (!raw || raw.startsWith("-")) throw new Error("--canary needs a component name: --canary <name>[,<name>...]");
|
|
@@ -372,7 +381,11 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
372
381
|
} else if (arg === "--pr-loop") {
|
|
373
382
|
result.prLoop = true;
|
|
374
383
|
} else if (arg === "--resume") {
|
|
375
|
-
|
|
384
|
+
// `chant approve <op> <gate> --resume` (#3683) takes no value; the
|
|
385
|
+
// fan-out and pull-request apply take the attempt file they resume.
|
|
386
|
+
const next = args[i + 1];
|
|
387
|
+
if (next === undefined || next.startsWith("-")) result.resumeRun = true;
|
|
388
|
+
else result.resume = args[++i];
|
|
376
389
|
} else if (arg === "--local") {
|
|
377
390
|
result.local = true;
|
|
378
391
|
} else if (arg === "--json") {
|
|
@@ -909,6 +922,13 @@ Ops:
|
|
|
909
922
|
run approve <op> <gate> Record a gate's resolution and wake the runtime
|
|
910
923
|
run cancel <name> Cancel the active run (requires --force)
|
|
911
924
|
run log <name> Show run history for an Op
|
|
925
|
+
run wave --spec <file> --wave <k> Plan, gate and apply wave k of an Op
|
|
926
|
+
waves spec (#3679): the job a generated waves
|
|
927
|
+
pipeline runs. --decide plans and decides only;
|
|
928
|
+
--share <i> applies one share of a decided wave
|
|
929
|
+
run resume [--op <name>] Start again each CI job that waits at a gate an
|
|
930
|
+
approval now answers, through the forge's API
|
|
931
|
+
(#3683); approves nothing. --dry-run lists them
|
|
912
932
|
run --generate <provider> Write one CI pipeline file per scheduled Op for
|
|
913
933
|
github, gitlab or forgejo (#2533), from every Op that
|
|
914
934
|
declares a schedule or from --spec <file.json>.
|
|
@@ -963,7 +983,10 @@ Ops:
|
|
|
963
983
|
counts only an approval sealed by a key the signers
|
|
964
984
|
file at base lists for --actor. --relayed-by
|
|
965
985
|
<principal> records who carried the approval to chant
|
|
966
|
-
for --actor (a follower, a bot); --sign covers it
|
|
986
|
+
for --actor (a follower, a bot); --sign covers it.
|
|
987
|
+
--resume then starts the CI job that waits at the
|
|
988
|
+
gate again (GitHub re-run, GitLab retry, Forgejo
|
|
989
|
+
dispatch) with CHANT_FORGE_TOKEN
|
|
967
990
|
|
|
968
991
|
graph Show Op dependency graph (--stacks for cross-stack order,
|
|
969
992
|
--format ir|mermaid|dot|layout for the lint-gated graph IR,
|
|
@@ -1815,6 +1838,9 @@ export const commandRegistry: CommandDef[] = [
|
|
|
1815
1838
|
{ name: "run signal", handler: runOpSignalRenamed },
|
|
1816
1839
|
{ name: "run cancel", handler: runOpCancel },
|
|
1817
1840
|
{ name: "run log", handler: runOpLog },
|
|
1841
|
+
{ name: "run wave", handler: runOpWaveCommand },
|
|
1842
|
+
// #3683 — start the CI jobs that wait at gates whose approval arrived. Imported on first use.
|
|
1843
|
+
{ name: "run resume", handler: async (ctx) => (await import("./handlers/gate-resume")).runResumeCommand(ctx) },
|
|
1818
1844
|
{ name: "run", handler: runOp },
|
|
1819
1845
|
|
|
1820
1846
|
{ name: "operator status", handler: runOperatorStatus },
|
package/src/cli/registry.ts
CHANGED
|
@@ -248,8 +248,12 @@ export interface ParsedArgs {
|
|
|
248
248
|
* <name>`, which generates one CI job per wave.
|
|
249
249
|
*/
|
|
250
250
|
waveGate?: string;
|
|
251
|
-
/** `chant components fan-out --wave <n>` (#3049) — run wave `n` of a `--wave-gate` fan-out and no other. */
|
|
251
|
+
/** `chant components fan-out --wave <n>` (#3049) — run wave `n` of a `--wave-gate` fan-out and no other. Also `chant run wave --wave <k>` (#3679). */
|
|
252
252
|
wave?: number;
|
|
253
|
+
/** `chant run wave --decide` (#3679) — plan a wide wave and decide its gate, applying nothing. */
|
|
254
|
+
decide?: boolean;
|
|
255
|
+
/** `chant run wave --share <i>` (#3679) — apply share `i` of a decided wide wave. */
|
|
256
|
+
share?: number;
|
|
253
257
|
/** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
|
|
254
258
|
canary?: string[];
|
|
255
259
|
/**
|
|
@@ -287,6 +291,12 @@ export interface ParsedArgs {
|
|
|
287
291
|
* partway under that approval.
|
|
288
292
|
*/
|
|
289
293
|
resume?: string;
|
|
294
|
+
/**
|
|
295
|
+
* `chant approve <op> <gate> --resume` (#3683): after recording the
|
|
296
|
+
* approval, start the CI job that waits at the gate again through the
|
|
297
|
+
* forge's API. Set when `--resume` is given no value.
|
|
298
|
+
*/
|
|
299
|
+
resumeRun?: boolean;
|
|
290
300
|
/** `chant audit --tier merge-worthy|all` */
|
|
291
301
|
tier?: string;
|
|
292
302
|
/**
|
package/src/lexicon.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AgentConfigImporter } from "./agents/importer";
|
|
|
10
10
|
import type { ArtifactIntegrity } from "./lexicon-integrity";
|
|
11
11
|
import type { GatedWavePipelineOptions } from "./components/gated-wave-pipeline";
|
|
12
12
|
import type { PrLoopPipelineOptions } from "./components/pr-pipeline";
|
|
13
|
+
import type { OpWaveJob, OpWavesSpec } from "./op/op-waves";
|
|
13
14
|
import type { OkfFile } from "./okf";
|
|
14
15
|
import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
|
|
15
16
|
import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
|
|
@@ -900,6 +901,23 @@ export interface OpPipelineResult {
|
|
|
900
901
|
jobs: OpPipelineJob[];
|
|
901
902
|
}
|
|
902
903
|
|
|
904
|
+
/**
|
|
905
|
+
* Options for an Op waves pipeline (#3679): the generic knobs of
|
|
906
|
+
* {@link ComponentPipelineOptions} that apply to every wave job, and the path
|
|
907
|
+
* of the spec file the jobs read.
|
|
908
|
+
*/
|
|
909
|
+
export interface OpWavesPipelineOptions
|
|
910
|
+
extends Pick<ComponentPipelineOptions, "image" | "beforeScript" | "extraScript" | "variables" | "opsStage" | "opsFileName"> {
|
|
911
|
+
/** The spec's path from the repository root. Every job runs `chant run wave --spec <specFile>`. */
|
|
912
|
+
specFile: string;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** An Op waves pipeline: one file, and its jobs in order. */
|
|
916
|
+
export interface OpWavesPipelineResult {
|
|
917
|
+
files: OpPipelineFile[];
|
|
918
|
+
jobs: OpWaveJob[];
|
|
919
|
+
}
|
|
920
|
+
|
|
903
921
|
/**
|
|
904
922
|
* Live status of a single deploy unit (a CloudFormation stack, a K8s release, …)
|
|
905
923
|
* addressed by its deployed name — the per-component presence signal
|
|
@@ -1325,6 +1343,14 @@ export interface LexiconPlugin {
|
|
|
1325
1343
|
options?: ComponentPipelineOptions,
|
|
1326
1344
|
): OpPipelineResult;
|
|
1327
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* Render ordered waves of Op runs, each behind one gate (#3679,
|
|
1348
|
+
* `./op/op-waves.ts`): one job per wave, or a deciding job and share jobs
|
|
1349
|
+
* for a wide one, each wave needing the one before. Only CI-provider
|
|
1350
|
+
* lexicons implement this.
|
|
1351
|
+
*/
|
|
1352
|
+
generateOpWavesPipeline?(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult;
|
|
1353
|
+
|
|
1328
1354
|
/**
|
|
1329
1355
|
* Host Op runs — the seam `chant run <op> --on <this lexicon>` dispatches to
|
|
1330
1356
|
* (#2121, epic #2114). Core ships one provider of its own, `local`, and
|
|
@@ -46,6 +46,7 @@ import { samePlanDigest } from "./plan-digest";
|
|
|
46
46
|
import { sortedJsonReplacer } from "../utils";
|
|
47
47
|
import { currentGateOrigin, type GateOrigin } from "./gate-origin";
|
|
48
48
|
import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
|
|
49
|
+
import type { GateRunLocator } from "../op/gate-resume";
|
|
49
50
|
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
50
51
|
|
|
51
52
|
const DIR = "_gates";
|
|
@@ -270,6 +271,12 @@ export interface PendingGateRecord {
|
|
|
270
271
|
expiresAt: string;
|
|
271
272
|
/** The address approval happens at, when the run knew one — see {@link resolveApprovalUrl}. */
|
|
272
273
|
url?: string;
|
|
274
|
+
/**
|
|
275
|
+
* The CI job that reached this gate (#3683), when it ran in one: what
|
|
276
|
+
* `chant approve --resume` and `chant run resume` hand the forge to start
|
|
277
|
+
* that job again (`../op/gate-resume.ts`). Absent outside CI.
|
|
278
|
+
*/
|
|
279
|
+
resume?: GateRunLocator;
|
|
273
280
|
/**
|
|
274
281
|
* The plan the run reached this gate with (#2300) — `computePlanDigest`'s
|
|
275
282
|
* output (`./plan-digest.ts`). This is what `chant approve` copies onto the
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3683 end to end on a real Forgejo with a runner: a job waits at a gate
|
|
3
|
+
* (exit 3), an approval lands, and {@link resumeGateRun} starts the job again
|
|
4
|
+
* without a push, from the locator {@link resolveGateRunLocator} reads off the
|
|
5
|
+
* job's own environment. The resume call uses a token with the
|
|
6
|
+
* `write:repository` scope alone, the least Forgejo's dispatch accepts.
|
|
7
|
+
*
|
|
8
|
+
* The job stands in for `chant run wave`: it prints its environment and exits
|
|
9
|
+
* 3 until a file named `approved` exists on the `lifecycle` branch. What a
|
|
10
|
+
* chant gate records and decides is covered by the unit tests; this proves
|
|
11
|
+
* the forge's half.
|
|
12
|
+
*
|
|
13
|
+
* Runs only with CHANT_E2E_FORGEJO_URL and CHANT_E2E_FORGEJO_TOKEN (a token
|
|
14
|
+
* with every scope, which creates and deletes the repository), against a
|
|
15
|
+
* Forgejo whose runner takes `docker` jobs. CHANT_E2E_FORGEJO_RESUME_TOKEN is
|
|
16
|
+
* the token the resume calls with; mint it with the `write:repository` scope
|
|
17
|
+
* alone (Forgejo mints tokens only over basic auth). Without it the resume
|
|
18
|
+
* uses the first token.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
22
|
+
import { resolveGateRunLocator, resumeGateRun } from "./gate-resume";
|
|
23
|
+
|
|
24
|
+
const URL_ = process.env.CHANT_E2E_FORGEJO_URL?.replace(/\/+$/, "");
|
|
25
|
+
const TOKEN = process.env.CHANT_E2E_FORGEJO_TOKEN;
|
|
26
|
+
const RESUME_TOKEN = process.env.CHANT_E2E_FORGEJO_RESUME_TOKEN ?? TOKEN;
|
|
27
|
+
const api = `${URL_}/api/v1`;
|
|
28
|
+
|
|
29
|
+
const WORKFLOW = `name: gate
|
|
30
|
+
on:
|
|
31
|
+
push:
|
|
32
|
+
branches: [main]
|
|
33
|
+
workflow_dispatch: {}
|
|
34
|
+
jobs:
|
|
35
|
+
wait:
|
|
36
|
+
runs-on: docker
|
|
37
|
+
steps:
|
|
38
|
+
- run: |
|
|
39
|
+
env | grep -E '^(GITHUB_|FORGEJO_|GITEA_)' | grep -v TOKEN | sort | sed 's/^/CHANT_E2E_ENV /'
|
|
40
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token \${{ secrets.GITHUB_TOKEN }}" "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/contents/approved?ref=lifecycle")
|
|
41
|
+
echo "approval lookup answered $code"
|
|
42
|
+
[ "$code" = 200 ] || exit 3
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
async function call(method: string, path: string, body?: unknown, token = TOKEN!): Promise<any> { // eslint-disable-line @typescript-eslint/no-explicit-any -- untyped forge JSON
|
|
46
|
+
const res = await fetch(`${api}${path}`, {
|
|
47
|
+
method,
|
|
48
|
+
headers: { Authorization: `token ${token}`, "Content-Type": "application/json" },
|
|
49
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
50
|
+
});
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text.slice(0, 300)}`);
|
|
53
|
+
try {
|
|
54
|
+
return text ? JSON.parse(text) : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
61
|
+
|
|
62
|
+
interface Run {
|
|
63
|
+
id: number;
|
|
64
|
+
status: string;
|
|
65
|
+
event: string;
|
|
66
|
+
trigger_event?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runs(repo: string): Promise<Run[]> {
|
|
70
|
+
const listed = await call("GET", `/repos/${repo}/actions/runs?limit=50`);
|
|
71
|
+
return (listed.workflow_runs ?? listed) as Run[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function finished(repo: string, pick: (r: Run) => boolean, timeoutMs = 240_000): Promise<Run> {
|
|
75
|
+
const end = Date.now() + timeoutMs;
|
|
76
|
+
while (Date.now() < end) {
|
|
77
|
+
const run = (await runs(repo)).find(pick);
|
|
78
|
+
if (run && !["waiting", "running", "blocked", "unknown"].includes(run.status)) return run;
|
|
79
|
+
await sleep(3000);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`no finished run in ${repo} within ${timeoutMs}ms`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe.skipIf(!URL_ || !TOKEN)("resume a waiting job on Forgejo (#3683)", () => {
|
|
85
|
+
const name = `chant-e2e-resume-${Date.now().toString(36)}`;
|
|
86
|
+
let owner = "";
|
|
87
|
+
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
if (owner) await call("DELETE", `/repos/${owner}/${name}`).catch(() => undefined);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the job waits, the approval lands, and a dispatch resumes it without a push", async () => {
|
|
93
|
+
owner = (await call("GET", "/user")).login;
|
|
94
|
+
await call("POST", "/user/repos", { name, auto_init: true, default_branch: "main", private: false });
|
|
95
|
+
const repo = `${owner}/${name}`;
|
|
96
|
+
await call("POST", `/repos/${repo}/branches`, { new_branch_name: "lifecycle", old_branch_name: "main" });
|
|
97
|
+
await call("POST", `/repos/${repo}/contents/.forgejo/workflows/gate.yml`, {
|
|
98
|
+
content: Buffer.from(WORKFLOW).toString("base64"),
|
|
99
|
+
message: "gate workflow",
|
|
100
|
+
branch: "main",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The push runs the job, which waits at the gate.
|
|
104
|
+
const waited = await finished(repo, () => true);
|
|
105
|
+
expect(waited.status).toBe("failure");
|
|
106
|
+
|
|
107
|
+
// Its environment, as the job printed it, is what a pending fact records.
|
|
108
|
+
const jobs = await call("GET", `/repos/${repo}/actions/runs/${waited.id}/jobs`);
|
|
109
|
+
const jobId = (jobs.jobs ?? jobs)[0].id;
|
|
110
|
+
const log = String(await call("GET", `/repos/${repo}/actions/jobs/${jobId}/logs`));
|
|
111
|
+
const env: Record<string, string> = {};
|
|
112
|
+
for (const m of log.matchAll(/CHANT_E2E_ENV ([A-Z_]+)=(.*)/g)) env[m[1]!] = m[2]!.trim();
|
|
113
|
+
const loc = resolveGateRunLocator(env);
|
|
114
|
+
expect(loc).toMatchObject({ forge: "forgejo", repo, run: String(waited.id), workflow: "gate.yml", ref: "main" });
|
|
115
|
+
|
|
116
|
+
const token = RESUME_TOKEN!;
|
|
117
|
+
// The job reaches Forgejo by the address its runner uses, which on a
|
|
118
|
+
// local stack is a container name; this test reaches it by its own.
|
|
119
|
+
const reachable = { ...loc!, api };
|
|
120
|
+
|
|
121
|
+
// Approve (here: the file the job looks for), then resume.
|
|
122
|
+
const approvedAt = new Date().toISOString();
|
|
123
|
+
await call("POST", `/repos/${repo}/contents/approved`, { content: Buffer.from("yes").toString("base64"), message: "approve", branch: "lifecycle" });
|
|
124
|
+
const outcome = await resumeGateRun(reachable, { token, since: approvedAt });
|
|
125
|
+
expect(outcome).toMatchObject({ status: "resumed", how: "dispatched gate.yml on main" });
|
|
126
|
+
|
|
127
|
+
const resumed = await finished(repo, (r) => r.id > waited.id);
|
|
128
|
+
expect(resumed.status).toBe("success");
|
|
129
|
+
expect(resumed.trigger_event ?? resumed.event).toBe("workflow_dispatch");
|
|
130
|
+
|
|
131
|
+
// A second resume finds the run that already started and starts nothing.
|
|
132
|
+
expect(await resumeGateRun(reachable, { token, since: approvedAt })).toMatchObject({ status: "skipped" });
|
|
133
|
+
expect((await runs(repo)).filter((r) => r.id > waited.id)).toHaveLength(1);
|
|
134
|
+
}, 600_000);
|
|
135
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { ForgeFetch } from "../pr-forge";
|
|
3
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
4
|
+
import { evaluateGate, memoryGateLedgerPort } from "./gate";
|
|
5
|
+
import {
|
|
6
|
+
isGateRunLocator,
|
|
7
|
+
resolutionAnswering,
|
|
8
|
+
resolveGateRunLocator,
|
|
9
|
+
resumeGateRun,
|
|
10
|
+
resumeTokenFromEnv,
|
|
11
|
+
type GateRunLocator,
|
|
12
|
+
} from "./gate-resume";
|
|
13
|
+
|
|
14
|
+
/** A fake forge: answers by "METHOD path" and records every call. */
|
|
15
|
+
function fakeForge(routes: Record<string, unknown>) {
|
|
16
|
+
const calls: Array<{ method: string; url: string; headers: Record<string, string>; body?: string }> = [];
|
|
17
|
+
const fetch: ForgeFetch = async (url, init) => {
|
|
18
|
+
calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: init.body } : {}) });
|
|
19
|
+
const key = `${init.method} ${new URL(url).pathname}${new URL(url).search}`;
|
|
20
|
+
const hit = Object.keys(routes).find((k) => key === k || key.startsWith(`${k}?`) || key.startsWith(k));
|
|
21
|
+
if (!hit) return { ok: false, status: 404, text: async () => `no route for ${key}` };
|
|
22
|
+
const body = routes[hit];
|
|
23
|
+
return { ok: true, status: 200, text: async () => (body === null ? "" : JSON.stringify(body)) };
|
|
24
|
+
};
|
|
25
|
+
return { fetch, calls };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GITHUB_ENV = {
|
|
29
|
+
GITHUB_ACTIONS: "true",
|
|
30
|
+
GITHUB_REPOSITORY: "acme/db",
|
|
31
|
+
GITHUB_RUN_ID: "42",
|
|
32
|
+
GITHUB_RUN_ATTEMPT: "1",
|
|
33
|
+
GITHUB_SERVER_URL: "https://github.com",
|
|
34
|
+
GITHUB_API_URL: "https://api.github.com",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("resolveGateRunLocator", () => {
|
|
38
|
+
test("GitHub Actions: the run and the attempt that waited", () => {
|
|
39
|
+
expect(resolveGateRunLocator(GITHUB_ENV)).toEqual({
|
|
40
|
+
forge: "github",
|
|
41
|
+
api: "https://api.github.com",
|
|
42
|
+
repo: "acme/db",
|
|
43
|
+
run: "42",
|
|
44
|
+
attempt: 1,
|
|
45
|
+
url: "https://github.com/acme/db/actions/runs/42",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("Forgejo Actions: the workflow file and branch a dispatch names", () => {
|
|
50
|
+
const loc = resolveGateRunLocator({
|
|
51
|
+
...GITHUB_ENV,
|
|
52
|
+
FORGEJO_ACTIONS: "true",
|
|
53
|
+
GITHUB_SERVER_URL: "https://code.example.org",
|
|
54
|
+
GITHUB_API_URL: "https://code.example.org/api/v1",
|
|
55
|
+
GITHUB_WORKFLOW: "migrations.yml",
|
|
56
|
+
GITHUB_REF_NAME: "main",
|
|
57
|
+
});
|
|
58
|
+
expect(loc).toMatchObject({ forge: "forgejo", api: "https://code.example.org/api/v1", workflow: "migrations.yml", ref: "main", run: "42" });
|
|
59
|
+
expect(isGateRunLocator(loc)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("GitLab CI: the pipeline, the job and its name", () => {
|
|
63
|
+
expect(
|
|
64
|
+
resolveGateRunLocator({
|
|
65
|
+
GITLAB_CI: "true",
|
|
66
|
+
CI_API_V4_URL: "https://gitlab.example.com/api/v4/",
|
|
67
|
+
CI_PROJECT_ID: "7",
|
|
68
|
+
CI_PIPELINE_ID: "100",
|
|
69
|
+
CI_JOB_ID: "555",
|
|
70
|
+
CI_JOB_NAME: "wave-2-staging",
|
|
71
|
+
}),
|
|
72
|
+
).toEqual({ forge: "gitlab", api: "https://gitlab.example.com/api/v4", repo: "7", run: "100", job: "555", jobName: "wave-2-staging" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("outside CI, or missing a part a resume needs, there is none", () => {
|
|
76
|
+
expect(resolveGateRunLocator({})).toBeUndefined();
|
|
77
|
+
expect(resolveGateRunLocator({ GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "a/b" })).toBeUndefined();
|
|
78
|
+
expect(resolveGateRunLocator({ ...GITHUB_ENV, GITEA_ACTIONS: "true", GITHUB_WORKFLOW: "Deploy" })).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("resumeGateRun", () => {
|
|
83
|
+
const github: GateRunLocator = { forge: "github", api: "https://api.github.com", repo: "acme/db", run: "42", attempt: 1 };
|
|
84
|
+
|
|
85
|
+
test("GitHub: re-runs the failed jobs of a finished run, with a bearer token", async () => {
|
|
86
|
+
const forge = fakeForge({
|
|
87
|
+
"GET /repos/acme/db/actions/runs/42": { status: "completed", conclusion: "failure", run_attempt: 1 },
|
|
88
|
+
"POST /repos/acme/db/actions/runs/42/rerun-failed-jobs": null,
|
|
89
|
+
});
|
|
90
|
+
const out = await resumeGateRun(github, { token: "t", fetch: forge.fetch });
|
|
91
|
+
expect(out).toMatchObject({ status: "resumed", how: "re-ran the failed jobs of run 42" });
|
|
92
|
+
expect(forge.calls.map((c) => c.method)).toEqual(["GET", "POST"]);
|
|
93
|
+
expect(forge.calls[1]!.headers.Authorization).toBe("Bearer t");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("GitHub: a run still going, or already re-run, is left alone", async () => {
|
|
97
|
+
const running = fakeForge({ "GET /repos/acme/db/actions/runs/42": { status: "in_progress", run_attempt: 1 } });
|
|
98
|
+
expect(await resumeGateRun(github, { token: "t", fetch: running.fetch })).toMatchObject({ status: "skipped" });
|
|
99
|
+
const rerun = fakeForge({ "GET /repos/acme/db/actions/runs/42": { status: "completed", conclusion: "failure", run_attempt: 2 } });
|
|
100
|
+
const out = await resumeGateRun(github, { token: "t", fetch: rerun.fetch });
|
|
101
|
+
expect(out).toMatchObject({ status: "skipped", reason: expect.stringContaining("already re-run") });
|
|
102
|
+
expect(rerun.calls.some((c) => c.method === "POST")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("GitLab: retries the waiting job, unless a newer job of its name exists", async () => {
|
|
106
|
+
const gitlab: GateRunLocator = { forge: "gitlab", api: "https://gl/api/v4", repo: "7", run: "100", job: "555", jobName: "wave-2-staging" };
|
|
107
|
+
const forge = fakeForge({
|
|
108
|
+
"GET /api/v4/projects/7/jobs/555": { status: "failed", name: "wave-2-staging" },
|
|
109
|
+
"GET /api/v4/projects/7/pipelines/100/jobs": [{ id: 555, name: "wave-2-staging" }],
|
|
110
|
+
"POST /api/v4/projects/7/jobs/555/retry": { id: 556 },
|
|
111
|
+
});
|
|
112
|
+
expect(await resumeGateRun(gitlab, { token: "t", fetch: forge.fetch })).toMatchObject({ status: "resumed", how: "retried job 555 as job 556" });
|
|
113
|
+
expect(forge.calls[2]!.headers["PRIVATE-TOKEN"]).toBe("t");
|
|
114
|
+
|
|
115
|
+
const retried = fakeForge({
|
|
116
|
+
"GET /api/v4/projects/7/jobs/555": { status: "failed", name: "wave-2-staging" },
|
|
117
|
+
"GET /api/v4/projects/7/pipelines/100/jobs": [{ id: 556, name: "wave-2-staging" }],
|
|
118
|
+
});
|
|
119
|
+
expect(await resumeGateRun(gitlab, { token: "t", fetch: retried.fetch })).toMatchObject({ status: "skipped", reason: expect.stringContaining("job 556") });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("Forgejo: dispatches the workflow on its branch, unless a run of it started after the approval", async () => {
|
|
123
|
+
const forgejo: GateRunLocator = { forge: "forgejo", api: "https://fj/api/v1", repo: "acme/db", run: "9", workflow: "migrations.yml", ref: "main" };
|
|
124
|
+
const forge = fakeForge({
|
|
125
|
+
"GET /api/v1/repos/acme/db/actions/runs/9": { status: "failure" },
|
|
126
|
+
"GET /api/v1/repos/acme/db/actions/runs": { workflow_runs: [{ id: 9, prettyref: "main", created: "2026-10-01T00:00:00Z" }] },
|
|
127
|
+
"POST /api/v1/repos/acme/db/actions/workflows/migrations.yml/dispatches": null,
|
|
128
|
+
});
|
|
129
|
+
const out = await resumeGateRun(forgejo, { token: "t", since: "2026-10-02T00:00:00Z", fetch: forge.fetch });
|
|
130
|
+
expect(out).toMatchObject({ status: "resumed", how: "dispatched migrations.yml on main" });
|
|
131
|
+
expect(JSON.parse(forge.calls[2]!.body!)).toEqual({ ref: "main" });
|
|
132
|
+
expect(forge.calls[2]!.headers.Authorization).toBe("token t");
|
|
133
|
+
|
|
134
|
+
const again = fakeForge({
|
|
135
|
+
"GET /api/v1/repos/acme/db/actions/runs/9": { status: "failure" },
|
|
136
|
+
"GET /api/v1/repos/acme/db/actions/runs": { workflow_runs: [{ id: 10, prettyref: "main", created: "2026-10-02T00:01:00Z" }] },
|
|
137
|
+
});
|
|
138
|
+
expect(await resumeGateRun(forgejo, { token: "t", since: "2026-10-02T00:00:00Z", fetch: again.fetch })).toMatchObject({ status: "skipped" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a refusal from the forge is thrown with its status", async () => {
|
|
142
|
+
const fetch: ForgeFetch = async () => ({ ok: false, status: 403, text: async () => "Resource not accessible by integration" });
|
|
143
|
+
await expect(resumeGateRun(github, { token: "t", fetch })).rejects.toThrow(/403/);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("the pending fact and its answer", () => {
|
|
148
|
+
test("a gate reached in CI records where its job runs", async () => {
|
|
149
|
+
const saved = { ...process.env };
|
|
150
|
+
try {
|
|
151
|
+
Object.assign(process.env, GITHUB_ENV);
|
|
152
|
+
const port = memoryGateLedgerPort();
|
|
153
|
+
await evaluateGate(port, { op: "migrations", gate: "migrations-wave-2", planDigest: `sha256:${"a".repeat(64)}`, now: "2026-10-01T00:00:00Z" });
|
|
154
|
+
expect(port.appended[0]!.resume).toMatchObject({ forge: "github", run: "42", attempt: 1 });
|
|
155
|
+
} finally {
|
|
156
|
+
for (const key of Object.keys(GITHUB_ENV)) if (!(key in saved)) delete process.env[key];
|
|
157
|
+
Object.assign(process.env, saved);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("an approval answers the standing fact only for its plan", () => {
|
|
162
|
+
const digest = `sha256:${"a".repeat(64)}`;
|
|
163
|
+
const standing: PendingGateRecord = {
|
|
164
|
+
version: 1, kind: "pending", op: "m", gate: "g", timestamp: "2026-10-01T00:00:00Z", expiresAt: "2026-10-03T00:00:00Z", planDigest: digest,
|
|
165
|
+
};
|
|
166
|
+
const approve = (planDigest: string, timestamp = "2026-10-01T01:00:00Z"): GateResolutionRecord => ({ version: 1, op: "m", gate: "g", resolvedBy: "alice", timestamp, planDigest });
|
|
167
|
+
expect(resolutionAnswering([approve(`sha256:${"b".repeat(64)}`)], standing)).toBeUndefined();
|
|
168
|
+
expect(resolutionAnswering([approve(digest, "2026-09-30T00:00:00Z")], standing)).toBeUndefined();
|
|
169
|
+
expect(resolutionAnswering([approve(digest)], standing)?.resolvedBy).toBe("alice");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("the token: CHANT_FORGE_TOKEN first, then the forge's own", () => {
|
|
173
|
+
expect(resumeTokenFromEnv("github", { GITHUB_TOKEN: "g", CHANT_FORGE_TOKEN: "c" })).toBe("c");
|
|
174
|
+
expect(resumeTokenFromEnv("forgejo", { GITHUB_TOKEN: "g" })).toBe("g");
|
|
175
|
+
expect(resumeTokenFromEnv("gitlab", { GITHUB_TOKEN: "g" })).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
});
|