@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.
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- 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-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +8 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/files.d.ts +8 -0
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/lexicon.d.ts +10 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +18 -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-review.d.ts +113 -0
- package/dist/op/gate-review.d.ts.map +1 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts +47 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +39 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +13 -2
- package/src/cli/commands/build.ts +2 -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-wave.ts +13 -1
- package/src/cli/main.ts +21 -3
- package/src/cli/plugins.ts +1 -1
- package/src/cli/registry.ts +8 -0
- package/src/discovery/files.ts +18 -0
- package/src/lexicon.ts +10 -1
- package/src/lifecycle/gate-ledger.ts +14 -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-review.test.ts +225 -0
- package/src/op/gate-review.ts +339 -0
- package/src/op/gate.ts +4 -0
- package/src/op/op-waves-run.ts +191 -13
- 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
|
-
|
|
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 },
|
package/src/cli/plugins.ts
CHANGED
|
@@ -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
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
/**
|
package/src/discovery/files.ts
CHANGED
|
@@ -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
|
+
});
|