@intentius/chant 0.124.10 → 0.124.12
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 +85 -10
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/gate-resume-run.d.ts +87 -0
- package/dist/gate-resume-run.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +44 -1
- package/dist/lifecycle/gate-applied.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +30 -14
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/ledger-port.d.ts +18 -0
- package/dist/lifecycle/ledger-port.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +10 -9
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-read.d.ts +23 -0
- package/dist/lifecycle/release-read.d.ts.map +1 -0
- package/dist/lifecycle/wave-plans.d.ts +51 -0
- package/dist/lifecycle/wave-plans.d.ts.map +1 -0
- package/dist/lint/typescript.d.ts +18 -0
- package/dist/lint/typescript.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/gate-resume-plan.d.ts +113 -0
- package/dist/op/gate-resume-plan.d.ts.map +1 -0
- package/dist/op/gate-resume.d.ts +8 -64
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +1 -20
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate-run-locator.d.ts +1 -1
- package/dist/op/index.d.ts +2 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/point-decision.d.ts +11 -0
- package/dist/point-decision.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.test.ts +140 -2
- package/src/cli/handlers/gate-resume.ts +174 -65
- package/src/cli/main.ts +8 -3
- package/src/codegen/pinned-upgrade.test.ts +8 -1
- package/src/gate-decision-bundle.test.ts +1 -0
- package/src/gate-resume-run-bundle.test.ts +49 -0
- package/src/gate-resume-run.ts +171 -0
- package/src/lifecycle/gate-applied.ts +63 -1
- package/src/lifecycle/gate-ledger.test.ts +7 -1
- package/src/lifecycle/gate-ledger.ts +1 -1
- package/src/lifecycle/git.ts +61 -18
- package/src/lifecycle/ledger-port.ts +17 -0
- package/src/lifecycle/ledger-readers-bundle.test.ts +47 -0
- package/src/lifecycle/ledger-readers.test.ts +139 -0
- package/src/lifecycle/release-ledger.ts +15 -42
- package/src/lifecycle/release-read.ts +69 -0
- package/src/lifecycle/wave-plans.ts +93 -0
- package/src/lint/typescript.ts +18 -0
- package/src/meta/lexicon-typescript-import.test.ts +42 -0
- package/src/op/gate-resume-plan-bundle.test.ts +49 -0
- package/src/op/gate-resume-plan.test.ts +107 -0
- package/src/op/gate-resume-plan.ts +228 -0
- package/src/op/gate-resume.test.ts +18 -0
- package/src/op/gate-resume.ts +16 -150
- package/src/op/gate-review.ts +2 -16
- package/src/op/gate-run-locator.ts +1 -1
- package/src/op/gate.ts +1 -1
- package/src/op/index.ts +2 -0
- package/src/op/op-waves-point-mcp.e2e.test.ts +148 -0
- package/src/op/op-waves-point.test.ts +60 -2
- package/src/op/op-waves-run.ts +6 -10
- package/src/point-decision.ts +19 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume the job that waits at a gate (#3683), bundle-safe (#3854).
|
|
3
|
+
*
|
|
4
|
+
* A CI job that reaches a gate nobody approved records a pending fact on
|
|
5
|
+
* `chant/lifecycle` and exits 3. Nothing re-runs it when the approval lands,
|
|
6
|
+
* because a push to the ledger branch starts no pipeline. This module closes
|
|
7
|
+
* that loop through the forge's API.
|
|
8
|
+
*
|
|
9
|
+
* When the job runs in CI, the pending fact it records carries a
|
|
10
|
+
* {@link GateRunLocator}, read from the job's environment by
|
|
11
|
+
* {@link resolveGateRunLocator}: which forge, the API base, the repository,
|
|
12
|
+
* the run, and what the forge needs to start it again. After an approval,
|
|
13
|
+
* {@link resumeGateRun} starts it again:
|
|
14
|
+
*
|
|
15
|
+
* | Forge | Call | Token |
|
|
16
|
+
* |---|---|---|
|
|
17
|
+
* | GitHub | `POST /repos/{repo}/actions/runs/{run}/rerun-failed-jobs` | `actions: write` (the job's `GITHUB_TOKEN` with that permission, or a fine-grained token with Actions read and write) |
|
|
18
|
+
* | GitLab | `POST /projects/{id}/jobs/{job}/retry` | a project or personal access token with the `api` scope and the Developer role; `CI_JOB_TOKEN` cannot retry jobs |
|
|
19
|
+
* | Forgejo | `POST /repos/{repo}/actions/workflows/{file}/dispatches` on the run's branch | a token with the `write:repository` scope; Forgejo has no API to re-run a run, so it starts a new run of the workflow |
|
|
20
|
+
*
|
|
21
|
+
* GitHub re-runs the failed job and the jobs that need it, at the same
|
|
22
|
+
* commit. GitLab retries the job, and the jobs it skipped run after it. A
|
|
23
|
+
* Forgejo dispatch runs the whole workflow again at the branch's head, so the
|
|
24
|
+
* jobs before the gate run again too; they must be safe to repeat (a wave
|
|
25
|
+
* whose runs already applied plans nothing and applies nothing new).
|
|
26
|
+
*
|
|
27
|
+
* Resuming approves nothing. The resumed job decides the gate again from the
|
|
28
|
+
* ledger, so resuming a run whose approval does not count only makes it wait
|
|
29
|
+
* again. {@link resumeGateRun} skips a run that is still going, that ended
|
|
30
|
+
* well, or that was already resumed (a later GitHub attempt, a newer GitLab
|
|
31
|
+
* job of the same name, a newer Forgejo run of the workflow), so a scheduled
|
|
32
|
+
* resume job can call it every few minutes without starting a run twice.
|
|
33
|
+
*
|
|
34
|
+
* This module imports no workspace, ledger, schema or git code, so a product
|
|
35
|
+
* that ships one bundled file (terragucci) can import it as
|
|
36
|
+
* `@intentius/chant/gate-resume-run` and resume a job in process. Which
|
|
37
|
+
* approval answers a pending fact is `./op/gate-resume.ts`'s
|
|
38
|
+
* `resolutionAnswering`, which reads the gate's quorum rules and does pull the
|
|
39
|
+
* workspace in; that module re-exports everything here.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { ForgeApiError, type ForgeFetch, type ForgeKind } from "./pr-forge";
|
|
43
|
+
import type { GateRunLocator } from "./op/gate-run-locator";
|
|
44
|
+
|
|
45
|
+
export { isGateRunLocator, resolveGateRunLocator, type GateRunLocator } from "./op/gate-run-locator";
|
|
46
|
+
|
|
47
|
+
/** What {@link resumeGateRun} did. */
|
|
48
|
+
export type GateResumeOutcome =
|
|
49
|
+
| { status: "resumed"; forge: ForgeKind; how: string; url?: string }
|
|
50
|
+
| { status: "skipped"; forge: ForgeKind; reason: string; url?: string }
|
|
51
|
+
/** Under `dryRun` (#3858): the run is gone and would be started again; nothing was started. */
|
|
52
|
+
| { status: "would-resume"; forge: ForgeKind; how: string; url?: string };
|
|
53
|
+
|
|
54
|
+
export interface ResumeGateRunOptions {
|
|
55
|
+
token: string;
|
|
56
|
+
/** The approval's time. A Forgejo run of the workflow created after it means the run was already resumed. */
|
|
57
|
+
since?: string;
|
|
58
|
+
fetch?: ForgeFetch;
|
|
59
|
+
/**
|
|
60
|
+
* Read the run as a resume would, and start nothing (#3858): say
|
|
61
|
+
* `would-resume` where it would start it. The calls are reads, so a token
|
|
62
|
+
* with read access to the forge's runs is enough.
|
|
63
|
+
*/
|
|
64
|
+
dryRun?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
|
68
|
+
|
|
69
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
70
|
+
|
|
71
|
+
function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
|
|
72
|
+
return async (method: string, path: string, body?: unknown): Promise<unknown> => {
|
|
73
|
+
const url = `${trimSlash(base)}${path}`;
|
|
74
|
+
const res = await fetchImpl(url, {
|
|
75
|
+
method,
|
|
76
|
+
headers: { ...headers, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
77
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
78
|
+
});
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
if (!res.ok) throw new ForgeApiError(method, url, res.status, text);
|
|
81
|
+
if (!text) return null;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Start the job a pending fact's locator names again, or say why not. Throws
|
|
92
|
+
* {@link ForgeApiError} when the forge refuses a call, such as a token
|
|
93
|
+
* without the access the table in the module doc lists.
|
|
94
|
+
*/
|
|
95
|
+
export async function resumeGateRun(loc: GateRunLocator, opts: ResumeGateRunOptions): Promise<GateResumeOutcome> {
|
|
96
|
+
const fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch);
|
|
97
|
+
const url = loc.url ? { url: loc.url } : {};
|
|
98
|
+
const skip = (reason: string): GateResumeOutcome => ({ status: "skipped", forge: loc.forge, reason, ...url });
|
|
99
|
+
const wouldResume = (how: string): GateResumeOutcome => ({ status: "would-resume", forge: loc.forge, how: `would ${how}`, ...url });
|
|
100
|
+
|
|
101
|
+
if (loc.forge === "github") {
|
|
102
|
+
const call = client(loc.api, { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" }, fetchImpl);
|
|
103
|
+
const path = `/repos/${loc.repo}/actions/runs/${loc.run}`;
|
|
104
|
+
const run = await call("GET", path);
|
|
105
|
+
if (!isObject(run)) throw new Error(`GET ${path} returned no run`);
|
|
106
|
+
if (run.status !== "completed") return skip(`run ${loc.run} is ${String(run.status)}, not finished`);
|
|
107
|
+
const attempt = typeof run.run_attempt === "number" ? run.run_attempt : undefined;
|
|
108
|
+
if (loc.attempt !== undefined && attempt !== undefined && attempt > loc.attempt) {
|
|
109
|
+
return skip(`run ${loc.run} was already re-run (attempt ${attempt}; attempt ${loc.attempt} waited)`);
|
|
110
|
+
}
|
|
111
|
+
if (run.conclusion === "success") return skip(`run ${loc.run} succeeded`);
|
|
112
|
+
if (opts.dryRun) return wouldResume(`re-run the failed jobs of run ${loc.run}`);
|
|
113
|
+
await call("POST", `${path}/rerun-failed-jobs`, {});
|
|
114
|
+
return { status: "resumed", forge: "github", how: `re-ran the failed jobs of run ${loc.run}`, ...url };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (loc.forge === "gitlab") {
|
|
118
|
+
const call = client(loc.api, { "PRIVATE-TOKEN": opts.token }, fetchImpl);
|
|
119
|
+
const project = `/projects/${encodeURIComponent(decodeURIComponent(loc.repo))}`;
|
|
120
|
+
const job = await call("GET", `${project}/jobs/${loc.job}`);
|
|
121
|
+
if (!isObject(job)) throw new Error(`GET ${project}/jobs/${loc.job} returned no job`);
|
|
122
|
+
const status = String(job.status);
|
|
123
|
+
if (status === "success") return skip(`job ${loc.job} succeeded`);
|
|
124
|
+
if (!["failed", "canceled", "skipped"].includes(status)) return skip(`job ${loc.job} is ${status}, not finished`);
|
|
125
|
+
const name = loc.jobName ?? (typeof job.name === "string" ? job.name : undefined);
|
|
126
|
+
if (name) {
|
|
127
|
+
// The pipeline's jobs list leaves retried jobs out, so the job of this name is its newest try.
|
|
128
|
+
const jobs = await call("GET", `${project}/pipelines/${loc.run}/jobs?per_page=100`);
|
|
129
|
+
const newest = Array.isArray(jobs) ? jobs.find((j) => isObject(j) && j.name === name) : undefined;
|
|
130
|
+
if (isObject(newest) && String(newest.id) !== String(loc.job)) {
|
|
131
|
+
return skip(`job ${loc.job} (${name}) was already retried as job ${String(newest.id)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (opts.dryRun) return wouldResume(`retry job ${loc.job}`);
|
|
135
|
+
const retried = await call("POST", `${project}/jobs/${loc.job}/retry`);
|
|
136
|
+
const id = isObject(retried) && retried.id !== undefined ? ` as job ${String(retried.id)}` : "";
|
|
137
|
+
return { status: "resumed", forge: "gitlab", how: `retried job ${loc.job}${id}`, ...url };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const call = client(loc.api, { Authorization: `token ${opts.token}`, Accept: "application/json" }, fetchImpl);
|
|
141
|
+
const repo = `/repos/${loc.repo}`;
|
|
142
|
+
const run = await call("GET", `${repo}/actions/runs/${loc.run}`);
|
|
143
|
+
if (!isObject(run)) throw new Error(`GET ${repo}/actions/runs/${loc.run} returned no run`);
|
|
144
|
+
const status = String(run.status);
|
|
145
|
+
if (status === "success") return skip(`run ${loc.run} succeeded`);
|
|
146
|
+
if (["waiting", "running", "blocked", "unknown"].includes(status)) return skip(`run ${loc.run} is ${status}, not finished`);
|
|
147
|
+
const listed = await call("GET", `${repo}/actions/runs?workflow_id=${encodeURIComponent(loc.workflow!)}&limit=50`);
|
|
148
|
+
const runs = isObject(listed) && Array.isArray(listed.workflow_runs) ? listed.workflow_runs : Array.isArray(listed) ? listed : [];
|
|
149
|
+
// Forgejo reports `created` in whole seconds.
|
|
150
|
+
const since = opts.since ? Math.floor(new Date(opts.since).getTime() / 1000) * 1000 : undefined;
|
|
151
|
+
const later = runs.find(
|
|
152
|
+
(r) =>
|
|
153
|
+
isObject(r) && Number(r.id) > Number(loc.run) &&
|
|
154
|
+
(r.prettyref === undefined || r.prettyref === loc.ref) &&
|
|
155
|
+
(since === undefined || (typeof r.created === "string" && new Date(r.created).getTime() >= since)),
|
|
156
|
+
);
|
|
157
|
+
if (isObject(later)) return skip(`run ${String(later.id)} of ${loc.workflow} started after the approval`);
|
|
158
|
+
if (opts.dryRun) return wouldResume(`dispatch ${loc.workflow} on ${loc.ref}`);
|
|
159
|
+
await call("POST", `${repo}/actions/workflows/${encodeURIComponent(loc.workflow!)}/dispatches`, { ref: loc.ref });
|
|
160
|
+
return { status: "resumed", forge: "forgejo", how: `dispatched ${loc.workflow} on ${loc.ref}`, ...url };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The token a resume uses: `CHANT_FORGE_TOKEN`, else the forge's own
|
|
165
|
+
* (`GITHUB_TOKEN` or `GH_TOKEN` on GitHub and Forgejo, `GITLAB_TOKEN` on
|
|
166
|
+
* GitLab), the same order the pull-request loop reads (`../pr-forge.ts`).
|
|
167
|
+
*/
|
|
168
|
+
export function resumeTokenFromEnv(forge: ForgeKind, env: Record<string, string | undefined> = process.env): string | undefined {
|
|
169
|
+
if (env.CHANT_FORGE_TOKEN) return env.CHANT_FORGE_TOKEN;
|
|
170
|
+
return forge === "gitlab" ? env.GITLAB_TOKEN : env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
171
|
+
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { samePlanDigest } from "./plan-digest";
|
|
21
21
|
import type { GateAuditApply } from "./gate-audit";
|
|
22
|
+
import type { GateRunLocator } from "../op/gate-run-locator";
|
|
22
23
|
|
|
23
24
|
/** One approval a wave applied under: the approval of `planDigest` made at `approvedAt` was used. */
|
|
24
25
|
export interface AppliedGateRecord {
|
|
@@ -38,6 +39,37 @@ export interface AppliedGateRecord {
|
|
|
38
39
|
review?: { pr?: number; head?: string };
|
|
39
40
|
runId?: string;
|
|
40
41
|
commit?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Where the run applying runs (#3858), as a pending fact's `resume` names
|
|
44
|
+
* it. A line that carries one and has no {@link FinishedGateRecord} is an
|
|
45
|
+
* apply that may have stopped: `chant run resume` starts that run again
|
|
46
|
+
* once it is gone, and the wave applies the rest under the same approval.
|
|
47
|
+
*/
|
|
48
|
+
resume?: GateRunLocator;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A line of `applied.jsonl` that a wave applying under an approval writes
|
|
53
|
+
* (#3858): chant's own `applied` line, or a product's `changes` line beside
|
|
54
|
+
* it (terragucci's, which names the changes the approved plans make). Both
|
|
55
|
+
* name the approval the apply ran under.
|
|
56
|
+
*/
|
|
57
|
+
export type AppliedGateLine = Omit<AppliedGateRecord, "kind"> & { kind: "applied" | "changes" };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How the apply an applied line names ended, in the same file (#3858). A
|
|
61
|
+
* product writes it when the apply finishes; an apply that was killed writes
|
|
62
|
+
* none. `applied` is that line's `timestamp`.
|
|
63
|
+
*/
|
|
64
|
+
export interface FinishedGateRecord {
|
|
65
|
+
version: 1;
|
|
66
|
+
kind: "finished";
|
|
67
|
+
op: string;
|
|
68
|
+
gate: string;
|
|
69
|
+
planDigest: string;
|
|
70
|
+
applied: string;
|
|
71
|
+
result: "applied" | "failed";
|
|
72
|
+
timestamp: string;
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
/** Where the applied lines of `op` live on `chant/lifecycle`: the directory and the file, as `writeBlobToPath` takes them. */
|
|
@@ -67,6 +99,36 @@ export function parseAppliedGateLines(text: string): AppliedGateRecord[] {
|
|
|
67
99
|
|
|
68
100
|
const time = (iso: string): number => new Date(iso).getTime();
|
|
69
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Every line of an `applied.jsonl` file a resume reads (#3858): the
|
|
104
|
+
* `applied` and `changes` lines, and the `finished` lines that say how an
|
|
105
|
+
* apply ended. Oldest first; a line that is none of them is skipped.
|
|
106
|
+
*/
|
|
107
|
+
export function parseAppliedGateFile(text: string): { applied: AppliedGateLine[]; finished: FinishedGateRecord[] } {
|
|
108
|
+
const applied: AppliedGateLine[] = [];
|
|
109
|
+
const finished: FinishedGateRecord[] = [];
|
|
110
|
+
for (const line of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
111
|
+
let r: Record<string, unknown>;
|
|
112
|
+
try {
|
|
113
|
+
r = JSON.parse(line) as Record<string, unknown>;
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (r?.version !== 1 || typeof r.gate !== "string" || typeof r.planDigest !== "string" || typeof r.timestamp !== "string") continue;
|
|
118
|
+
if ((r.kind === "applied" || r.kind === "changes") && typeof r.approvedAt === "string") {
|
|
119
|
+
applied.push({ op: "", approvedBy: "", ...r } as unknown as AppliedGateLine);
|
|
120
|
+
} else if (r.kind === "finished" && typeof r.applied === "string" && (r.result === "applied" || r.result === "failed")) {
|
|
121
|
+
finished.push({ op: "", ...r } as unknown as FinishedGateRecord);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { applied, finished };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The finished line for the apply `line` names, or undefined while it has none. */
|
|
128
|
+
export function finishedApply(finished: readonly FinishedGateRecord[], line: Pick<AppliedGateLine, "gate" | "planDigest" | "timestamp">): FinishedGateRecord | undefined {
|
|
129
|
+
return finished.find((f) => f.gate === line.gate && samePlanDigest(f.planDigest, line.planDigest) && time(f.applied) === time(line.timestamp));
|
|
130
|
+
}
|
|
131
|
+
|
|
70
132
|
/**
|
|
71
133
|
* Whether an approval is spent: a wave applied under an approval of the same
|
|
72
134
|
* gate and digest made at or after it. One that names no digest is never
|
|
@@ -76,7 +138,7 @@ const time = (iso: string): number => new Date(iso).getTime();
|
|
|
76
138
|
* review not yet recorded has no `timestamp`.
|
|
77
139
|
*/
|
|
78
140
|
export function isApprovalSpent(
|
|
79
|
-
applied: readonly AppliedGateRecord[],
|
|
141
|
+
applied: readonly Pick<AppliedGateRecord, "gate" | "planDigest" | "approvedAt" | "review">[],
|
|
80
142
|
approval: { gate: string; planDigest?: string; timestamp?: string; review?: { pr?: number; head?: string } },
|
|
81
143
|
): boolean {
|
|
82
144
|
const digest = approval.planDigest;
|
|
@@ -446,7 +446,13 @@ describe("gate ledger newline termination (#3849)", () => {
|
|
|
446
446
|
test("every append ends the file in a newline, with no blank or merged lines", async () => {
|
|
447
447
|
await withTestDir(async (dir) => {
|
|
448
448
|
await initRepo(dir);
|
|
449
|
-
await appendPendingGate(
|
|
449
|
+
await appendPendingGate(
|
|
450
|
+
{
|
|
451
|
+
op: "nl-op", gate: "g1", planDigest: "sha256:" + "a".repeat(64),
|
|
452
|
+
timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2026-01-08T00:00:00.000Z",
|
|
453
|
+
},
|
|
454
|
+
{ cwd: dir },
|
|
455
|
+
);
|
|
450
456
|
const one = await readBlobFromPath("_gates", "nl-op.jsonl", { cwd: dir });
|
|
451
457
|
expect(one!.endsWith("\n")).toBe(true);
|
|
452
458
|
await appendGateResolution(
|
|
@@ -45,7 +45,7 @@ import { isNeverOverMcpGateOp } from "../op/gate-name";
|
|
|
45
45
|
import { sortedJsonReplacer } from "../utils";
|
|
46
46
|
import { currentGateOrigin, type GateOrigin } from "./gate-origin";
|
|
47
47
|
import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
|
|
48
|
-
import type { GateRunLocator } from "../op/gate-
|
|
48
|
+
import type { GateRunLocator } from "../op/gate-run-locator";
|
|
49
49
|
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
50
50
|
|
|
51
51
|
// The facts a pending record is written with live apart, so a bundle can import them without git (#3816).
|
package/src/lifecycle/git.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* no working tree changes.
|
|
6
6
|
*/
|
|
7
7
|
import { getRuntime } from "../runtime-adapter";
|
|
8
|
-
import { resolveMemberLedger } from "./member-ledger";
|
|
8
|
+
import { memberLedgerPrefix, resolveMemberLedger } from "./member-ledger";
|
|
9
9
|
import { LIFECYCLE_LOCAL_NOTE } from "./local-note";
|
|
10
|
+
import type { LedgerReadPort } from "./ledger-port";
|
|
10
11
|
|
|
11
12
|
const STATE_BRANCH = "chant/lifecycle";
|
|
12
13
|
|
|
@@ -18,9 +19,29 @@ const STATE_BRANCH = "chant/lifecycle";
|
|
|
18
19
|
* build records) goes through here, so none of them names the prefix itself.
|
|
19
20
|
* See ./member-ledger.ts for who counts as a member.
|
|
20
21
|
*/
|
|
21
|
-
export async function ledgerDir(dir: string, opts?:
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
export async function ledgerDir(dir: string, opts?: LedgerLocation): Promise<string> {
|
|
23
|
+
return `${await ledgerPrefixOf(opts)}${dir}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where to read, when the caller is not standing in the member it reads
|
|
28
|
+
* (#3865): a reader such as behold's lists many members from one checkout. Both
|
|
29
|
+
* fields override what `cwd` resolves to; `ledgerPrefix` wins over `member`.
|
|
30
|
+
*/
|
|
31
|
+
export interface LedgerLocation {
|
|
32
|
+
cwd?: string;
|
|
33
|
+
/** A member's name, or its path of names in a nested workspace, outermost first. `"."` and `[]` name the flat layout. */
|
|
34
|
+
member?: string | readonly string[];
|
|
35
|
+
/** The prefix `resolveMemberLedger` returns: `""`, or `_members/<member>/` per level. */
|
|
36
|
+
ledgerPrefix?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function ledgerPrefixOf(opts?: LedgerLocation): Promise<string> {
|
|
40
|
+
if (opts?.ledgerPrefix !== undefined) return opts.ledgerPrefix;
|
|
41
|
+
if (opts?.member !== undefined) {
|
|
42
|
+
return memberLedgerPrefix((typeof opts.member === "string" ? [opts.member] : [...opts.member]).filter((m) => m !== "."));
|
|
43
|
+
}
|
|
44
|
+
return (await resolveMemberLedger(opts?.cwd ?? process.cwd())).prefix;
|
|
24
45
|
}
|
|
25
46
|
|
|
26
47
|
/**
|
|
@@ -348,7 +369,7 @@ export async function writeLedgerFiles(
|
|
|
348
369
|
export async function readBlobFromPath(
|
|
349
370
|
environment: string,
|
|
350
371
|
filename: string,
|
|
351
|
-
opts?:
|
|
372
|
+
opts?: LedgerLocation,
|
|
352
373
|
): Promise<string | null> {
|
|
353
374
|
const rt = getRuntime();
|
|
354
375
|
const dir = await ledgerDir(environment, opts);
|
|
@@ -507,7 +528,7 @@ export async function appendReleaseRecordLine(
|
|
|
507
528
|
*/
|
|
508
529
|
export async function readReleaseLedgerLines(
|
|
509
530
|
environment: string,
|
|
510
|
-
opts?:
|
|
531
|
+
opts?: LedgerLocation,
|
|
511
532
|
): Promise<string[]> {
|
|
512
533
|
const content = await readBlobFromPath(environment, "releases.jsonl", opts);
|
|
513
534
|
if (!content) return [];
|
|
@@ -520,7 +541,21 @@ export async function readReleaseLedgerLines(
|
|
|
520
541
|
* `releases.jsonl` entry. Used by `chant components status` (no env arg) to
|
|
521
542
|
* discover which environments to report on.
|
|
522
543
|
*/
|
|
523
|
-
export async function listLedgerEnvironments(opts?:
|
|
544
|
+
export async function listLedgerEnvironments(opts?: LedgerLocation): Promise<string[]> {
|
|
545
|
+
const envs: string[] = [];
|
|
546
|
+
for (const name of await listLedgerTrees(opts)) {
|
|
547
|
+
const hasLedger = await readBlobFromPath(name, "releases.jsonl", opts);
|
|
548
|
+
if (hasLedger) envs.push(name);
|
|
549
|
+
}
|
|
550
|
+
return envs.sort();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The directories directly under the ledger's base: the root for the flat
|
|
555
|
+
* layout, `_members/<member>/` for a member. `[]` when the branch, or the
|
|
556
|
+
* base, is absent.
|
|
557
|
+
*/
|
|
558
|
+
export async function listLedgerTrees(opts?: LedgerLocation): Promise<string[]> {
|
|
524
559
|
const tip = await getStateBranchTip(opts?.cwd);
|
|
525
560
|
if (!tip) return [];
|
|
526
561
|
const rt = getRuntime();
|
|
@@ -535,18 +570,12 @@ export async function listLedgerEnvironments(opts?: { cwd?: string }): Promise<s
|
|
|
535
570
|
{ cwd: opts?.cwd },
|
|
536
571
|
);
|
|
537
572
|
if (rootResult.exitCode !== 0) return [];
|
|
538
|
-
|
|
539
|
-
const
|
|
540
|
-
const lines = rootResult.stdout.trim().split("\n").filter(Boolean);
|
|
541
|
-
for (const line of lines) {
|
|
573
|
+
const trees: string[] = [];
|
|
574
|
+
for (const line of rootResult.stdout.trim().split("\n").filter(Boolean)) {
|
|
542
575
|
const match = line.match(/^(\d+)\s+(\w+)\s+([0-9a-f]+)\t(.+)$/);
|
|
543
|
-
if (
|
|
544
|
-
const [, , type, , name] = match;
|
|
545
|
-
if (type !== "tree") continue;
|
|
546
|
-
const hasLedger = await readBlobFromPath(name, "releases.jsonl", opts);
|
|
547
|
-
if (hasLedger) envs.push(name);
|
|
576
|
+
if (match && match[2] === "tree") trees.push(match[4]);
|
|
548
577
|
}
|
|
549
|
-
return
|
|
578
|
+
return trees;
|
|
550
579
|
}
|
|
551
580
|
|
|
552
581
|
/**
|
|
@@ -557,7 +586,7 @@ export async function listLedgerEnvironments(opts?: { cwd?: string }): Promise<s
|
|
|
557
586
|
* manifest (`_builds/<digest>.json`) without duplicating this `git ls-tree
|
|
558
587
|
* --name-only` call.
|
|
559
588
|
*/
|
|
560
|
-
export async function listFilesInDir(dir: string, opts?:
|
|
589
|
+
export async function listFilesInDir(dir: string, opts?: LedgerLocation): Promise<string[]> {
|
|
561
590
|
const tip = await getStateBranchTip(opts?.cwd);
|
|
562
591
|
if (!tip) return [];
|
|
563
592
|
const rt = getRuntime();
|
|
@@ -1405,3 +1434,17 @@ function baseName(dir: string): string {
|
|
|
1405
1434
|
function depth(dir: string): number {
|
|
1406
1435
|
return dir === "" ? 0 : dir.split("/").length;
|
|
1407
1436
|
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* The branch's read side as a {@link LedgerReadPort} for the member `opts`
|
|
1440
|
+
* names (#3865): what the bundle-safe readers (`./release-read.ts`,
|
|
1441
|
+
* `./wave-plans.ts`) are handed. A product with its own way to read the
|
|
1442
|
+
* branch passes its own port instead and never loads this module.
|
|
1443
|
+
*/
|
|
1444
|
+
export function gitLedgerReadPort(opts?: LedgerLocation): LedgerReadPort {
|
|
1445
|
+
return {
|
|
1446
|
+
read: (dir, file) => readBlobFromPath(dir, file, opts),
|
|
1447
|
+
list: (dir) => listFilesInDir(dir, opts),
|
|
1448
|
+
trees: () => listLedgerTrees(opts),
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read side of `chant/lifecycle` that the bundle-safe readers take
|
|
3
|
+
* (#3865). Paths are relative to one project's ledger: the flat root, or a
|
|
4
|
+
* workspace member's `_members/<member>/`. Which of those a port reads is the
|
|
5
|
+
* port's business; `gitLedgerReadPort` (`./git.ts`) takes a member or a
|
|
6
|
+
* prefix, and a product that reads the branch its own way supplies its own.
|
|
7
|
+
*
|
|
8
|
+
* Types only, so a bundle that imports a reader loads no git code.
|
|
9
|
+
*/
|
|
10
|
+
export interface LedgerReadPort {
|
|
11
|
+
/** The file at `<dir>/<file>`, or null when the branch, the directory or the file is absent. */
|
|
12
|
+
read(dir: string, file: string): Promise<string | null>;
|
|
13
|
+
/** The names directly under `<dir>/`, or `[]` when absent. */
|
|
14
|
+
list(dir: string): Promise<string[]>;
|
|
15
|
+
/** The directories directly under the ledger's root: every environment, and `_gates`, `_wave-plans` and the rest. */
|
|
16
|
+
trees(): Promise<string[]>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release and wave-plan readers bundle small (#3865, as #3764 did for the
|
|
3
|
+
* gate audit). A product that shows a workspace member's environments or the
|
|
4
|
+
* recorded wave plans bundles one file with no TypeScript toolchain, so
|
|
5
|
+
* `@intentius/chant/lifecycle/release-read` and
|
|
6
|
+
* `@intentius/chant/lifecycle/wave-plans` must not reach `typescript`, esbuild,
|
|
7
|
+
* zod, git, or chant's fold, lint, codegen, workspace and op modules.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { build } from "esbuild";
|
|
12
|
+
import { describe, expect, test } from "vitest";
|
|
13
|
+
import { REPO } from "../workspace/__fixtures__/contract-repo";
|
|
14
|
+
|
|
15
|
+
const BUDGET_BYTES = 16 * 1024;
|
|
16
|
+
|
|
17
|
+
const FORBIDDEN = [/node_modules\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/workspace\//, /\/lifecycle\/git\.ts$/, /\/lifecycle\/member-ledger\.ts$/, /\/lifecycle\/release-ledger\.ts$/, /\/op\//];
|
|
18
|
+
|
|
19
|
+
async function bundle(entry: string) {
|
|
20
|
+
const result = await build({
|
|
21
|
+
entryPoints: [join(REPO, "packages", "core", "src", "lifecycle", entry)],
|
|
22
|
+
bundle: true,
|
|
23
|
+
platform: "node",
|
|
24
|
+
format: "esm",
|
|
25
|
+
write: false,
|
|
26
|
+
metafile: true,
|
|
27
|
+
outdir: "out",
|
|
28
|
+
logLevel: "silent",
|
|
29
|
+
});
|
|
30
|
+
const inputs = Object.keys(result.metafile.inputs);
|
|
31
|
+
for (const pattern of FORBIDDEN) {
|
|
32
|
+
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
33
|
+
}
|
|
34
|
+
const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
|
|
35
|
+
expect(bytes).toBeLessThan(BUDGET_BYTES);
|
|
36
|
+
return inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("the ledger readers bundles", () => {
|
|
40
|
+
test("the wave plans reader holds itself and nothing else", async () => {
|
|
41
|
+
expect(await bundle("wave-plans.ts")).toEqual(["packages/core/src/lifecycle/wave-plans.ts"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the release reader holds itself and the legacy digest flag", async () => {
|
|
45
|
+
expect(await bundle("release-read.ts")).toEqual(["packages/core/src/lifecycle/legacy-digest.ts", "packages/core/src/lifecycle/release-read.ts"]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3865 — the readers a product uses for a workspace member's release
|
|
3
|
+
* environments and op-waves `_wave-plans` records: pure over a port, and the
|
|
4
|
+
* git port over a member's prefix.
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { LedgerReadPort } from "./ledger-port";
|
|
12
|
+
import { gitLedgerReadPort, listLedgerEnvironments, writeLedgerFiles } from "./git";
|
|
13
|
+
import { listReleaseEnvironmentsOn, parseReleaseLedger, readReleaseRecords } from "./release-read";
|
|
14
|
+
import { listReleaseEnvironments, readReleaseLedger } from "./release-ledger";
|
|
15
|
+
import { listWavePlanSpecs, parseHeadPlans, readAllHeadPlans, readHeadPlanDigests, readHeadPlans } from "./wave-plans";
|
|
16
|
+
|
|
17
|
+
/** A port over a flat map of `<dir>/<file>` to text. */
|
|
18
|
+
function memoryPort(files: Record<string, string>): LedgerReadPort {
|
|
19
|
+
return {
|
|
20
|
+
read: async (dir, file) => files[`${dir}/${file}`] ?? null,
|
|
21
|
+
list: async (dir) => [...new Set(Object.keys(files).filter((p) => p.startsWith(`${dir}/`)).map((p) => p.slice(dir.length + 1).split("/")[0]))],
|
|
22
|
+
trees: async () => [...new Set(Object.keys(files).map((p) => p.split("/")[0]))],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const record = (env: string, digest = "sha256:" + "a".repeat(64)) =>
|
|
27
|
+
JSON.stringify({ version: 1, component: "svc", env, digest, gitSha: "abc", runId: "r1", timestamp: "2026-01-01T00:00:00.000Z", actor: "ci" });
|
|
28
|
+
|
|
29
|
+
const plans = (head: string, digests: string[]) =>
|
|
30
|
+
JSON.stringify({ version: 1, name: "ship", head, timestamp: "2026-01-01T00:00:00.000Z", waves: digests.map((digest, i) => ({ wave: i + 1, name: `w${i + 1}`, digest })) });
|
|
31
|
+
|
|
32
|
+
describe("release readers over a port", () => {
|
|
33
|
+
test("list the environments that hold a ledger, sorted, and parse their records", async () => {
|
|
34
|
+
const port = memoryPort({
|
|
35
|
+
"staging/releases.jsonl": record("staging") + "\n",
|
|
36
|
+
"prod/releases.jsonl": record("prod") + "\nnot json\n" + JSON.stringify({ version: 1 }) + "\n",
|
|
37
|
+
"prod/snapshot.json": "{}",
|
|
38
|
+
"_gates/deploy.jsonl": "",
|
|
39
|
+
});
|
|
40
|
+
expect(await listReleaseEnvironmentsOn(port)).toEqual(["prod", "staging"]);
|
|
41
|
+
const { records, malformed } = await readReleaseRecords(port, "prod");
|
|
42
|
+
expect(records.map((r) => r.env)).toEqual(["prod"]);
|
|
43
|
+
expect(malformed).toBe(2);
|
|
44
|
+
expect(await readReleaseRecords(port, "nope")).toEqual({ records: [], malformed: 0 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("parseReleaseLedger skips a non-object line", () => {
|
|
48
|
+
expect(parseReleaseLedger(["null", "3"]).malformed).toBe(2);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("wave plans readers", () => {
|
|
53
|
+
const files = {
|
|
54
|
+
"_wave-plans/ship/aaa.json": plans("aaa", ["sha256:one", "sha256:two"]),
|
|
55
|
+
"_wave-plans/ship/bbb.json": plans("bbb", ["sha256:three"]),
|
|
56
|
+
"_wave-plans/ship/broken.json": "{",
|
|
57
|
+
"_wave-plans/other/ccc.json": plans("ccc", ["sha256:four"]),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
test("read one head, and null for a missing or unreadable one", async () => {
|
|
61
|
+
const port = memoryPort(files);
|
|
62
|
+
expect((await readHeadPlans(port, "ship", "aaa"))?.waves.map((w) => w.digest)).toEqual(["sha256:one", "sha256:two"]);
|
|
63
|
+
expect(await readHeadPlans(port, "ship", "zzz")).toBeNull();
|
|
64
|
+
expect(await readHeadPlans(port, "ship", "broken")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("read every head of a spec, by head, with the digests by wave", async () => {
|
|
68
|
+
const port = memoryPort(files);
|
|
69
|
+
expect([...(await readAllHeadPlans(port, "ship")).keys()]).toEqual(["aaa", "bbb"]);
|
|
70
|
+
const digests = await readHeadPlanDigests(port, "ship");
|
|
71
|
+
expect(digests.get("aaa")!.get(2)).toBe("sha256:two");
|
|
72
|
+
expect(digests.get("bbb")!.get(1)).toBe("sha256:three");
|
|
73
|
+
expect(await listWavePlanSpecs(port)).toEqual(["other", "ship"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the parser refuses a record of another shape", () => {
|
|
77
|
+
expect(parseHeadPlans(null)).toBeNull();
|
|
78
|
+
expect(parseHeadPlans("[]")).toBeNull();
|
|
79
|
+
expect(parseHeadPlans(JSON.stringify({ version: 2, name: "a", head: "b", timestamp: "t", waves: [] }))).toBeNull();
|
|
80
|
+
expect(parseHeadPlans(JSON.stringify({ version: 1, name: "a", head: "b", timestamp: "t", waves: [{ wave: "1", name: "x", digest: "d" }] }))).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("a member's records on chant/lifecycle", () => {
|
|
85
|
+
const scratch: string[] = [];
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
async function fixture(): Promise<string> {
|
|
91
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-ledger-readers-")));
|
|
92
|
+
scratch.push(root);
|
|
93
|
+
const git = (...args: string[]) => {
|
|
94
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf-8" });
|
|
95
|
+
if (r.status !== 0) throw new Error(r.stderr);
|
|
96
|
+
};
|
|
97
|
+
git("init", "-q", "-b", "main");
|
|
98
|
+
git("config", "user.email", "t@chant.dev");
|
|
99
|
+
git("config", "user.name", "T");
|
|
100
|
+
writeFileSync(join(root, "f"), "");
|
|
101
|
+
git("add", "-A");
|
|
102
|
+
git("commit", "-q", "-m", "init");
|
|
103
|
+
await writeLedgerFiles(
|
|
104
|
+
[
|
|
105
|
+
{ path: "_members/api/prod/releases.jsonl", content: record("prod") + "\n" },
|
|
106
|
+
{ path: "_members/api/staging/releases.jsonl", content: record("staging") + "\n" },
|
|
107
|
+
{ path: "_members/web/edge/releases.jsonl", content: record("edge") + "\n" },
|
|
108
|
+
{ path: "_members/api/_wave-plans/ship/aaa.json", content: plans("aaa", ["sha256:one"]) },
|
|
109
|
+
{ path: "_members/web/_wave-plans/ship/bbb.json", content: plans("bbb", ["sha256:two"]) },
|
|
110
|
+
{ path: "legacy/releases.jsonl", content: record("legacy") + "\n" },
|
|
111
|
+
],
|
|
112
|
+
"seed",
|
|
113
|
+
{ cwd: root },
|
|
114
|
+
);
|
|
115
|
+
return root;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test("listReleaseEnvironments and readReleaseLedger take a member or its prefix, from outside it", async () => {
|
|
119
|
+
const cwd = await fixture();
|
|
120
|
+
expect(await listReleaseEnvironments({ cwd, member: "api" })).toEqual(["prod", "staging"]);
|
|
121
|
+
expect(await listReleaseEnvironments({ cwd, ledgerPrefix: "_members/web/" })).toEqual(["edge"]);
|
|
122
|
+
expect(await listReleaseEnvironments({ cwd, member: "." })).toEqual(["legacy"]);
|
|
123
|
+
expect(await listLedgerEnvironments({ cwd })).toEqual(["legacy"]);
|
|
124
|
+
expect((await readReleaseLedger("prod", { cwd, member: "api" })).records).toHaveLength(1);
|
|
125
|
+
expect((await readReleaseLedger("prod", { cwd, member: "web" })).records).toHaveLength(0);
|
|
126
|
+
expect((await readReleaseLedger("prod", { cwd, ledgerPrefix: "_members/api/" })).records).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("the git port reads each member's wave plans and nothing of another's", async () => {
|
|
130
|
+
const cwd = await fixture();
|
|
131
|
+
const api = gitLedgerReadPort({ cwd, member: "api" });
|
|
132
|
+
const web = gitLedgerReadPort({ cwd, member: ["web"] });
|
|
133
|
+
expect([...(await readHeadPlanDigests(api, "ship")).entries()].map(([h, m]) => [h, [...m]])).toEqual([["aaa", [[1, "sha256:one"]]]]);
|
|
134
|
+
expect([...(await readAllHeadPlans(web, "ship")).keys()]).toEqual(["bbb"]);
|
|
135
|
+
expect(await listWavePlanSpecs(api)).toEqual(["ship"]);
|
|
136
|
+
expect(await listReleaseEnvironmentsOn(api)).toEqual(["prod", "staging"]);
|
|
137
|
+
expect(await listWavePlanSpecs(gitLedgerReadPort({ cwd, member: "." }))).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
});
|