@intentius/chant 0.116.0 → 0.118.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.
Files changed (47) hide show
  1. package/dist/build.d.ts +1 -1
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/gate-resume.d.ts +44 -0
  5. package/dist/cli/handlers/gate-resume.d.ts.map +1 -0
  6. package/dist/cli/handlers/operator.d.ts +1 -0
  7. package/dist/cli/handlers/operator.d.ts.map +1 -1
  8. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +8 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/discovery/files.d.ts +8 -0
  13. package/dist/discovery/files.d.ts.map +1 -1
  14. package/dist/lexicon.d.ts +10 -1
  15. package/dist/lexicon.d.ts.map +1 -1
  16. package/dist/lifecycle/gate-ledger.d.ts +18 -0
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/op/gate-resume.d.ts +104 -0
  19. package/dist/op/gate-resume.d.ts.map +1 -0
  20. package/dist/op/gate-review.d.ts +113 -0
  21. package/dist/op/gate-review.d.ts.map +1 -0
  22. package/dist/op/gate.d.ts.map +1 -1
  23. package/dist/op/op-waves-run.d.ts +47 -1
  24. package/dist/op/op-waves-run.d.ts.map +1 -1
  25. package/dist/op/op-waves.d.ts +39 -0
  26. package/dist/op/op-waves.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/build.ts +13 -2
  29. package/src/cli/commands/build.ts +2 -1
  30. package/src/cli/handlers/gate-resume.test.ts +51 -0
  31. package/src/cli/handlers/gate-resume.ts +166 -0
  32. package/src/cli/handlers/operator.ts +8 -2
  33. package/src/cli/handlers/run-wave.ts +13 -1
  34. package/src/cli/main.ts +21 -3
  35. package/src/cli/plugins.ts +1 -1
  36. package/src/cli/registry.ts +8 -0
  37. package/src/discovery/files.ts +18 -0
  38. package/src/lexicon.ts +10 -1
  39. package/src/lifecycle/gate-ledger.ts +14 -0
  40. package/src/op/gate-resume.e2e.test.ts +135 -0
  41. package/src/op/gate-resume.test.ts +177 -0
  42. package/src/op/gate-resume.ts +267 -0
  43. package/src/op/gate-review.test.ts +225 -0
  44. package/src/op/gate-review.ts +339 -0
  45. package/src/op/gate.ts +4 -0
  46. package/src/op/op-waves-run.ts +191 -13
  47. package/src/op/op-waves.ts +51 -0
@@ -5,13 +5,25 @@
5
5
 
6
6
  import { readFileSync } from "node:fs";
7
7
  import { relative, resolve } from "node:path";
8
- import { parseOpWavesSpec, runOpWave } from "../../op/op-waves-run";
8
+ import { parseOpWavesSpec, recordOpWaveHeadPlans, runOpWave } from "../../op/op-waves-run";
9
9
  import { formatError } from "../format";
10
10
  import type { CommandContext } from "../registry";
11
11
 
12
12
  export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
13
13
  const { args } = ctx;
14
14
  const specFile = args.opsSpec;
15
+ if (specFile && args.recordPlans) {
16
+ try {
17
+ const spec = parseOpWavesSpec(readFileSync(resolve(specFile), "utf-8"), `--spec ${specFile}`);
18
+ const plans = await recordOpWaveHeadPlans({ spec });
19
+ if (args.json) console.log(JSON.stringify(plans, null, 2));
20
+ else for (const w of plans.waves) console.error(`wave ${w.wave} (${w.name}) at ${plans.head.slice(0, 12)}: ${w.digest}`);
21
+ return 0;
22
+ } catch (err) {
23
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
24
+ return 1;
25
+ }
26
+ }
15
27
  if (!specFile || args.wave === undefined) {
16
28
  console.error(formatError({
17
29
  message: "`chant run wave` needs --spec <file> and --wave <k>",
package/src/cli/main.ts CHANGED
@@ -108,6 +108,7 @@ const BOOLEAN_FLAGS = new Set([
108
108
  "--root-only",
109
109
  "--generated",
110
110
  "--decide",
111
+ "--record-plans",
111
112
  ]);
112
113
 
113
114
  /**
@@ -359,6 +360,8 @@ export function parseArgs(args: string[]): ParsedArgs {
359
360
  result.wave = wave;
360
361
  } else if (arg === "--decide") {
361
362
  result.decide = true;
363
+ } else if (arg === "--record-plans") {
364
+ result.recordPlans = true;
362
365
  } else if (arg === "--share") {
363
366
  const raw = args[++i];
364
367
  const share = Number(raw);
@@ -381,7 +384,11 @@ export function parseArgs(args: string[]): ParsedArgs {
381
384
  } else if (arg === "--pr-loop") {
382
385
  result.prLoop = true;
383
386
  } else if (arg === "--resume") {
384
- result.resume = args[++i];
387
+ // `chant approve <op> <gate> --resume` (#3683) takes no value; the
388
+ // fan-out and pull-request apply take the attempt file they resume.
389
+ const next = args[i + 1];
390
+ if (next === undefined || next.startsWith("-")) result.resumeRun = true;
391
+ else result.resume = args[++i];
385
392
  } else if (arg === "--local") {
386
393
  result.local = true;
387
394
  } else if (arg === "--json") {
@@ -921,7 +928,13 @@ Ops:
921
928
  run wave --spec <file> --wave <k> Plan, gate and apply wave k of an Op
922
929
  waves spec (#3679): the job a generated waves
923
930
  pipeline runs. --decide plans and decides only;
924
- --share <i> applies one share of a decided wave
931
+ --share <i> applies one share of a decided wave.
932
+ --record-plans (no --wave), in a pull request's
933
+ pipeline: plan every wave at its head and record the
934
+ digests a pr-review approval mode compares
935
+ run resume [--op <name>] Start again each CI job that waits at a gate an
936
+ approval now answers, through the forge's API
937
+ (#3683); approves nothing. --dry-run lists them
925
938
  run --generate <provider> Write one CI pipeline file per scheduled Op for
926
939
  github, gitlab or forgejo (#2533), from every Op that
927
940
  declares a schedule or from --spec <file.json>.
@@ -976,7 +989,10 @@ Ops:
976
989
  counts only an approval sealed by a key the signers
977
990
  file at base lists for --actor. --relayed-by
978
991
  <principal> records who carried the approval to chant
979
- for --actor (a follower, a bot); --sign covers it
992
+ for --actor (a follower, a bot); --sign covers it.
993
+ --resume then starts the CI job that waits at the
994
+ gate again (GitHub re-run, GitLab retry, Forgejo
995
+ dispatch) with CHANT_FORGE_TOKEN
980
996
 
981
997
  graph Show Op dependency graph (--stacks for cross-stack order,
982
998
  --format ir|mermaid|dot|layout for the lint-gated graph IR,
@@ -1829,6 +1845,8 @@ export const commandRegistry: CommandDef[] = [
1829
1845
  { name: "run cancel", handler: runOpCancel },
1830
1846
  { name: "run log", handler: runOpLog },
1831
1847
  { name: "run wave", handler: runOpWaveCommand },
1848
+ // #3683 — start the CI jobs that wait at gates whose approval arrived. Imported on first use.
1849
+ { name: "run resume", handler: async (ctx) => (await import("./handlers/gate-resume")).runResumeCommand(ctx) },
1832
1850
  { name: "run", handler: runOp },
1833
1851
 
1834
1852
  { name: "operator status", handler: runOperatorStatus },
@@ -211,7 +211,7 @@ export function collectBuildRootContributors(
211
211
  .filter((plugin) => typeof plugin.buildRoots === "function")
212
212
  // `entities` is not bindable here — discovery has not run — so the merge
213
213
  // hands it in when it calls the closure (#1828 / SOPS provenance).
214
- .map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
214
+ .map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities, ...(ctx?.sourceDir !== undefined ? { sourceDir: ctx.sourceDir } : {}) }));
215
215
  return [...codegen, ...roots];
216
216
  }
217
217
 
@@ -252,6 +252,8 @@ export interface ParsedArgs {
252
252
  wave?: number;
253
253
  /** `chant run wave --decide` (#3679) — plan a wide wave and decide its gate, applying nothing. */
254
254
  decide?: boolean;
255
+ /** `chant run wave --record-plans` (#3684) — plan every wave at a pull request's head and record the digests for a `pr-review` gate. */
256
+ recordPlans?: boolean;
255
257
  /** `chant run wave --share <i>` (#3679) — apply share `i` of a decided wide wave. */
256
258
  share?: number;
257
259
  /** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
@@ -291,6 +293,12 @@ export interface ParsedArgs {
291
293
  * partway under that approval.
292
294
  */
293
295
  resume?: string;
296
+ /**
297
+ * `chant approve <op> <gate> --resume` (#3683): after recording the
298
+ * approval, start the CI job that waits at the gate again through the
299
+ * forge's API. Set when `--resume` is given no value.
300
+ */
301
+ resumeRun?: boolean;
294
302
  /** `chant audit --tier merge-worthy|all` */
295
303
  tier?: string;
296
304
  /**
@@ -116,3 +116,21 @@ export async function findInfraFiles(path: string, options?: FindInfraFilesOptio
116
116
  accept: (name, full) => isSourceFileName(name) && !hasDiscoveryMarkerSync(full),
117
117
  });
118
118
  }
119
+
120
+ /**
121
+ * Find the files under `path` that `accept` wants, through the same walk and
122
+ * the same project globs as {@link findInfraFiles}: a lexicon that reads
123
+ * source of its own beside the TypeScript (the sql lexicon's `.sql` files)
124
+ * sees exactly the tree source discovery sees, so `dist`, git-ignored paths,
125
+ * child projects and the project's `exclude` globs are left out for it too.
126
+ */
127
+ export async function findSourceFiles(path: string, accept: (name: string, full: string) => boolean, options?: FindInfraFilesOptions): Promise<string[]> {
128
+ const globs = options?.globs === null ? undefined : (options?.globs ?? (await resolveDiscoveryGlobs(path)));
129
+ return walkDiscovery({
130
+ walker: "source",
131
+ root: path,
132
+ globs,
133
+ excludeDirs: await workspaceMemberDirs(path),
134
+ accept,
135
+ });
136
+ }
package/src/lexicon.ts CHANGED
@@ -984,6 +984,15 @@ export interface BuildRootContext {
984
984
  * treat an absent map as an empty one.
985
985
  */
986
986
  entities?: ReadonlyMap<string, Declarable>;
987
+ /**
988
+ * The directory this build discovered its source in (the path handed to
989
+ * `build()`, which `--src` or `sourceDir` chose), absolute. A contributor
990
+ * that reads files beside the typed source (the sql lexicon's `.sql`
991
+ * files) reads them here, so it sees the tree discovery saw. Optional: a
992
+ * caller that is not a build (the graph paths) omits it, and a hook then
993
+ * falls back to `sourceDir` in `config`, resolved against `projectRoot`.
994
+ */
995
+ sourceDir?: string;
987
996
  }
988
997
 
989
998
  /**
@@ -1003,7 +1012,7 @@ export interface BuildRootContribution {
1003
1012
  * the argument is still assignable, which is what every pre-#1828 hook does.
1004
1013
  */
1005
1014
  export type BuildRootContributor = (
1006
- ctx: Pick<BuildRootContext, "entities">,
1015
+ ctx: Pick<BuildRootContext, "entities" | "sourceDir">,
1007
1016
  ) => Promise<BuildRootContribution>;
1008
1017
 
1009
1018
  /**
@@ -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";
@@ -221,6 +222,13 @@ export interface GateResolutionRecord {
221
222
  * verifies. Absent on an unsigned approval.
222
223
  */
223
224
  seal?: GateApprovalSeal;
225
+ /**
226
+ * The forge review this approval came from (#3684): a wave whose approval
227
+ * mode is `pr-review` writes it when the merged pull request's head was
228
+ * approved by a writer other than its author and planned this digest.
229
+ * `resolvedBy` is the first of `approvers`.
230
+ */
231
+ review?: { pr: number; head: string; approvers: string[] };
224
232
  /**
225
233
  * The answer record that passed the gate, on a gate that asks a decision
226
234
  * point (#3170). The run writes this resolution itself when the point's
@@ -270,6 +278,12 @@ export interface PendingGateRecord {
270
278
  expiresAt: string;
271
279
  /** The address approval happens at, when the run knew one — see {@link resolveApprovalUrl}. */
272
280
  url?: string;
281
+ /**
282
+ * The CI job that reached this gate (#3683), when it ran in one: what
283
+ * `chant approve --resume` and `chant run resume` hand the forge to start
284
+ * that job again (`../op/gate-resume.ts`). Absent outside CI.
285
+ */
286
+ resume?: GateRunLocator;
273
287
  /**
274
288
  * The plan the run reached this gate with (#2300) — `computePlanDigest`'s
275
289
  * 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
+ });