@intentius/chant 0.124.14 → 0.124.15
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 +6 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -0
- package/dist/change-set.d.ts +24 -2
- package/dist/change-set.d.ts.map +1 -1
- package/dist/change-set.js +50 -1
- package/dist/ci-jobs.d.ts +227 -0
- package/dist/ci-jobs.d.ts.map +1 -0
- package/dist/ci-jobs.js +448 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +3 -2
- package/dist/composite.d.ts +10 -2
- package/dist/composite.d.ts.map +1 -1
- package/dist/composite.js +8 -0
- package/dist/discovery/collect.d.ts +2 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/collect.js +16 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-import.js +2 -107
- package/dist/discovery/host-composite.d.ts +35 -0
- package/dist/discovery/host-composite.d.ts.map +1 -0
- package/dist/discovery/host-composite.js +93 -0
- package/dist/discovery/index.d.ts +9 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +1 -1
- package/dist/gate-decision.d.ts +20 -9
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +72 -19
- package/dist/lifecycle/gate-audit.d.ts +69 -2
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.js +76 -2
- package/dist/lifecycle/gate-ledger.d.ts +24 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +9 -1
- package/dist/op/activities/activity-contracts.d.ts +1 -1
- package/dist/op/activities/activity-contracts.js +1 -1
- package/dist/op/activities/change-set.d.ts +8 -3
- package/dist/op/activities/change-set.d.ts.map +1 -1
- package/dist/op/activities/change-set.js +5 -2
- package/dist/op/activities/reconcile.d.ts +28 -16
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/activities/reconcile.js +34 -20
- package/dist/op/builders.d.ts +1 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +1 -1
- package/dist/op/op-waves-run.d.ts +1 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +3 -3
- package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.js +17 -1
- package/dist/workspace/__fixtures__/sessions.d.ts +1 -1
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/sessions.js +3 -3
- package/dist/workspace/change-set.schema.json +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +3 -1
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +5 -2
- package/dist/workspace/class-data.d.ts +84 -0
- package/dist/workspace/class-data.d.ts.map +1 -0
- package/dist/workspace/class-data.js +238 -0
- package/dist/workspace/kinds.d.ts +3 -7
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +6 -40
- package/dist/workspace/principal-classes.d.ts +4 -11
- package/dist/workspace/principal-classes.d.ts.map +1 -1
- package/dist/workspace/principal-classes.js +11 -94
- package/package.json +1 -1
- package/src/build.ts +8 -0
- package/src/change-set.test.ts +48 -0
- package/src/change-set.ts +52 -2
- package/src/ci-jobs.test.ts +49 -0
- package/src/ci-jobs.ts +515 -0
- package/src/components/pr-pipeline.ts +3 -2
- package/src/composite.ts +15 -3
- package/src/discovery/collect.ts +15 -1
- package/src/discovery/fold-build-parity.test.ts +71 -0
- package/src/discovery/fold-import.ts +2 -113
- package/src/discovery/fold-project-provenance.test.ts +21 -4
- package/src/discovery/host-composite.ts +101 -0
- package/src/discovery/index.ts +11 -1
- package/src/gate-decision-bundle.test.ts +5 -4
- package/src/gate-decision-identity.test.ts +59 -6
- package/src/gate-decision.ts +81 -21
- package/src/lifecycle/gate-audit.test.ts +55 -1
- package/src/lifecycle/gate-audit.ts +116 -3
- package/src/lifecycle/gate-ledger.test.ts +20 -1
- package/src/lifecycle/gate-ledger.ts +33 -1
- package/src/op/activities/activity-contracts.ts +1 -1
- package/src/op/activities/change-set.ts +11 -3
- package/src/op/activities/lexicon-upgrade.test.ts +2 -0
- package/src/op/activities/reconcile.test.ts +62 -2
- package/src/op/activities/reconcile.ts +46 -22
- package/src/op/builders.ts +1 -1
- package/src/op/op-waves-run.ts +4 -3
- package/src/op/op-waves.test.ts +2 -0
- package/src/op/work-lease-run.test.ts +12 -4
- package/src/workspace/__fixtures__/contract-repo.ts +17 -1
- package/src/workspace/__fixtures__/sessions.ts +3 -3
- package/src/workspace/change-set.schema.json +5 -3
- package/src/workspace/ci-green-workflow.test.ts +4 -4
- package/src/workspace/ci-green-workflow.ts +5 -2
- package/src/workspace/class-data.test.ts +64 -0
- package/src/workspace/class-data.ts +236 -0
- package/src/workspace/kinds.ts +6 -35
- package/src/workspace/principal-classes.ts +11 -94
- package/src/workspace/records-formats.test.ts +7 -2
- package/src/workspace/records-sessions-write.test.ts +3 -3
- package/src/workspace/status-contract.e2e.test.ts +37 -0
- package/src/workspace/status-contract.test.ts +23 -21
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { deriveGateAudit, gateLedgerLines, gateLedgerLinesFromLog, readGateAuditLedgers, isOverrideGate } from "./gate-audit";
|
|
2
|
+
import { readGateAuditDecisions, deriveGateAudit, gateLedgerLines, gateLedgerLinesFromLog, readGateAuditLedgers, isOverrideGate } from "./gate-audit";
|
|
3
3
|
import { memoryGateLedgerPort } from "../op/gate";
|
|
4
4
|
|
|
5
5
|
const D1 = "jcs1-sha256:" + "a".repeat(64);
|
|
@@ -12,6 +12,15 @@ const resolution = (o: Record<string, unknown> = {}) =>
|
|
|
12
12
|
const ledger = (op: string, ...texts: string[]) => ({ op, lines: texts.map((text) => ({ text })) });
|
|
13
13
|
|
|
14
14
|
describe("lifecycle/gate-audit", () => {
|
|
15
|
+
test("a hold's heldBy and approvalMode are surfaced (#3886, #3887)", () => {
|
|
16
|
+
const { entries } = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ approvalMode: "sealed", heldBy: { hook: "migrate", moment: "before-apply", target: "vpc" } }))] });
|
|
17
|
+
expect(entries[0]).toMatchObject({ approvalMode: "sealed", heldBy: { hook: "migrate", moment: "before-apply", target: "vpc" } });
|
|
18
|
+
expect(entries[0]!.detail).toEqual({});
|
|
19
|
+
const bad = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ approvalMode: "x", heldBy: { hook: 1 } }))] });
|
|
20
|
+
expect(bad.entries[0]!.heldBy).toBeUndefined();
|
|
21
|
+
expect(bad.entries[0]!.approvalMode).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
15
24
|
test("a pending fact's wave is surfaced (#3839)", () => {
|
|
16
25
|
const { entries } = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ wave: { number: 2, name: "prod", op: "tg" } }))] });
|
|
17
26
|
expect(entries[0]).toMatchObject({ wave: { number: 2, name: "prod", op: "tg" } });
|
|
@@ -179,4 +188,49 @@ describe("lifecycle/gate-audit", () => {
|
|
|
179
188
|
expect(fromPort.entries.map((e) => e.kind)).toEqual(["approval-requested", "approved"]);
|
|
180
189
|
expect(fromPort.entries.map((e) => e.at)).toEqual(fromText.entries.map((e) => e.at));
|
|
181
190
|
});
|
|
191
|
+
|
|
192
|
+
describe("op-wave decision records (#3810)", () => {
|
|
193
|
+
const outcomes = [
|
|
194
|
+
{ moment: "before-plan", name: "lint", exit: 0, outcome: "passed" },
|
|
195
|
+
{ moment: "after-plan", name: "cost", target: "prod", exit: 2, outcome: "held" },
|
|
196
|
+
{ moment: "before-apply", name: "backup", exit: 1, outcome: "failed" },
|
|
197
|
+
{ moment: "after-apply", name: "skip", exit: 0, outcome: "skipped", reason: "nothing applied" },
|
|
198
|
+
];
|
|
199
|
+
const record = { wave: 1, name: "prod", op: "tf-apply", gate: "wave-1", digest: D1, heldBy: ["cost"], outcomes };
|
|
200
|
+
|
|
201
|
+
test("each outcome becomes a hook entry linked to the approval and the apply", () => {
|
|
202
|
+
const { entries, gaps } = deriveGateAudit({
|
|
203
|
+
ledgers: [ledger("tf-apply", pending(), resolution())],
|
|
204
|
+
applies: [{ ref: "reports/1", at: "2026-10-01T12:00:00.000Z", gate: "wave-1", digest: D1 }],
|
|
205
|
+
decisions: [{ ref: "wave-1.json", at: "2026-10-01T12:05:00.000Z", record }],
|
|
206
|
+
});
|
|
207
|
+
expect(gaps).toEqual([]);
|
|
208
|
+
const hooks = entries.filter((e) => e.kind === "hook");
|
|
209
|
+
expect(hooks.map((h) => h.hook?.outcome)).toEqual(["passed", "held", "failed", "skipped"]);
|
|
210
|
+
expect(hooks[1]!.hook).toMatchObject({ moment: "after-plan", name: "cost", target: "prod", exit: 2 });
|
|
211
|
+
expect(hooks[3]!.hook?.reason).toBe("nothing applied");
|
|
212
|
+
const approved = entries.find((e) => e.kind === "approved")!;
|
|
213
|
+
const applied = entries.find((e) => e.kind === "applied")!;
|
|
214
|
+
for (const h of hooks) expect(h).toMatchObject({ under: approved.id, applied: applied.id, gate: "wave-1", digest: D1, wave: { number: 1, name: "prod" } });
|
|
215
|
+
expect(new Set(hooks.map((h) => h.id)).size).toBe(4);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("text input, no approval on file, and a malformed record", () => {
|
|
219
|
+
const { entries, gaps } = deriveGateAudit({
|
|
220
|
+
ledgers: [],
|
|
221
|
+
decisions: [{ ref: "a.json", at: "2026-10-01T12:00:00.000Z", text: JSON.stringify(record) }, { ref: "b.json", at: "2026-10-01T12:00:00.000Z", text: "nope" }],
|
|
222
|
+
});
|
|
223
|
+
expect(entries.filter((e) => e.kind === "hook")).toHaveLength(4);
|
|
224
|
+
expect(entries[0]).not.toHaveProperty("under");
|
|
225
|
+
expect(gaps.map((g) => g.kind)).toEqual(["malformed-line"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("readGateAuditDecisions reads the json files under a directory", async () => {
|
|
229
|
+
const files: Record<string, string> = { "w1.json": JSON.stringify(record), "notes.txt": "x" };
|
|
230
|
+
const port = { list: async () => Object.keys(files), read: async (_d: string, f: string) => files[f] ?? null };
|
|
231
|
+
const got = await readGateAuditDecisions(port, "_wave-decisions/tf", "2026-10-01T12:00:00.000Z");
|
|
232
|
+
expect(got.map((d) => d.ref)).toEqual(["_wave-decisions/tf/w1.json"]);
|
|
233
|
+
expect(deriveGateAudit({ ledgers: [], decisions: got }).entries).toHaveLength(4);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
182
236
|
});
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* | `revoked` | a resolution a commit removed from the file (`subject` says which) |
|
|
22
22
|
* | `applied` | an apply the caller passes in, with the approval it ran under |
|
|
23
23
|
* | `override-spent` | an override an apply the caller passes in cites |
|
|
24
|
+
* | `hook` | a hook outcome in an op-wave decision record the caller passes in |
|
|
24
25
|
*
|
|
25
26
|
* An override gate is, by default, any gate of the op `policy-override`
|
|
26
27
|
* (terragucci writes one gate per root) or named `override-<rule>` (SQL
|
|
@@ -79,6 +80,8 @@ import { canonicalJson } from "../effect-receipt";
|
|
|
79
80
|
import { contentDigest } from "../content-digest";
|
|
80
81
|
import { samePlanDigest } from "./plan-digest";
|
|
81
82
|
import type { GateLedgerPort } from "../op/gate";
|
|
83
|
+
import type { LedgerReadPort } from "./ledger-port";
|
|
84
|
+
import type { GateApprovalSource } from "../op/op-waves";
|
|
82
85
|
|
|
83
86
|
export const GATE_AUDIT_KINDS = [
|
|
84
87
|
"approval-requested",
|
|
@@ -88,6 +91,7 @@ export const GATE_AUDIT_KINDS = [
|
|
|
88
91
|
"revoked",
|
|
89
92
|
"applied",
|
|
90
93
|
"override-spent",
|
|
94
|
+
"hook",
|
|
91
95
|
] as const;
|
|
92
96
|
export type GateAuditKind = (typeof GATE_AUDIT_KINDS)[number];
|
|
93
97
|
|
|
@@ -155,7 +159,7 @@ export interface GateAuditSource {
|
|
|
155
159
|
commit?: string;
|
|
156
160
|
/** The commit that added the line, or for a revocation removed it, when the line came from history. */
|
|
157
161
|
author?: GateLedgerCommit;
|
|
158
|
-
change: "added" | "removed" | "apply";
|
|
162
|
+
change: "added" | "removed" | "apply" | "decision";
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
export interface GateAuditEntry {
|
|
@@ -193,8 +197,16 @@ export interface GateAuditEntry {
|
|
|
193
197
|
description?: string;
|
|
194
198
|
/** Pending facts an op wave wrote for a policy denial or a hold (#3839): the wave they belong to. */
|
|
195
199
|
wave?: { number: number; name: string; op?: string };
|
|
200
|
+
/** Pending facts of a hold (#3886): the hook that holds the wave. */
|
|
201
|
+
heldBy?: { hook: string; moment: string; target?: string };
|
|
202
|
+
/** Pending facts of an op wave (#3861): the approval mode the wave asked under. */
|
|
203
|
+
approvalMode?: GateApprovalSource;
|
|
196
204
|
/** Overrides: the rules the denial named (from the denial line when there is one). */
|
|
197
205
|
rules?: string[];
|
|
206
|
+
/** A hook entry: the hook's moment, name, run, how it came out, and why a moment was skipped (#3810). */
|
|
207
|
+
hook?: { moment: string; name: string; target?: string; outcome: "passed" | "failed" | "held" | "skipped"; exit: number; reason?: string };
|
|
208
|
+
/** A hook entry: the applied entry of the same wave and digest, if the caller passed that apply in. */
|
|
209
|
+
applied?: string;
|
|
198
210
|
/** A revoked entry: what was revoked. */
|
|
199
211
|
subject?: "approval" | "override";
|
|
200
212
|
/** An applied entry: the approval entry it ran under, if one is held. An override-spent entry: the override entry. */
|
|
@@ -212,6 +224,35 @@ export interface GateAuditGap {
|
|
|
212
224
|
detail: string;
|
|
213
225
|
}
|
|
214
226
|
|
|
227
|
+
/**
|
|
228
|
+
* An op-wave decision record (`.chant/op-waves/<name>/wave-<k>.json`, written
|
|
229
|
+
* by `chant run wave`), as far as the audit reads it: the wave's gate and
|
|
230
|
+
* digest, and the `outcomes` of its hooks (#3804, #3811, #3847). Typed
|
|
231
|
+
* structurally so this file imports nothing from `../op/`.
|
|
232
|
+
*/
|
|
233
|
+
export interface WaveDecisionRecord {
|
|
234
|
+
wave: number;
|
|
235
|
+
name: string;
|
|
236
|
+
op: string;
|
|
237
|
+
gate: string;
|
|
238
|
+
digest?: string;
|
|
239
|
+
heldBy?: string[];
|
|
240
|
+
heldOn?: unknown;
|
|
241
|
+
outcomes?: ReadonlyArray<{ moment: string; name: string; target?: string; exit: number; outcome: string; reason?: string }>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A decision record the caller passes in. The record carries no time and no
|
|
246
|
+
* place, so `ref` (a file path, an artifact, a row) and `at` (when it was
|
|
247
|
+
* written) come from the caller. Give the parsed `record`, or its `text`.
|
|
248
|
+
*/
|
|
249
|
+
export interface GateAuditDecision {
|
|
250
|
+
ref: string;
|
|
251
|
+
at: string;
|
|
252
|
+
record?: unknown;
|
|
253
|
+
text?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
215
256
|
export interface GateAuditOptions {
|
|
216
257
|
/** Whether a gate is an override gate. Default: op `policy-override`, or a gate named `override-...`. */
|
|
217
258
|
isOverride?: (op: string, gate: string) => boolean;
|
|
@@ -228,6 +269,8 @@ export interface GateAuditOptions {
|
|
|
228
269
|
export interface GateAuditInput extends GateAuditOptions {
|
|
229
270
|
ledgers: readonly GateAuditLedger[];
|
|
230
271
|
applies?: readonly GateAuditApply[];
|
|
272
|
+
/** Op-wave decision records: each hook outcome in them becomes a `hook` entry linked to the wave's approval and apply (#3810). */
|
|
273
|
+
decisions?: readonly GateAuditDecision[];
|
|
231
274
|
}
|
|
232
275
|
|
|
233
276
|
export const POLICY_OVERRIDE_OP = "policy-override";
|
|
@@ -241,13 +284,18 @@ type Row = Record<string, unknown>;
|
|
|
241
284
|
|
|
242
285
|
const KNOWN = new Set([
|
|
243
286
|
"version", "kind", "op", "gate", "resolvedBy", "relayedBy", "timestamp", "note", "url", "planDigest", "environment",
|
|
244
|
-
"approver", "seal", "review", "runId", "expiresAt", "description", "wave", "rules", "pr", "head", "reviewers",
|
|
287
|
+
"approver", "seal", "review", "runId", "expiresAt", "description", "wave", "heldBy", "approvalMode", "rules", "pr", "head", "reviewers",
|
|
245
288
|
]);
|
|
246
289
|
|
|
247
290
|
const waveOf = (v: unknown): { number: number; name: string; op?: string } | undefined => {
|
|
248
291
|
const w = v as { number?: unknown; name?: unknown; op?: unknown } | null;
|
|
249
292
|
return w && typeof w === "object" && typeof w.number === "number" && typeof w.name === "string" ? { number: w.number, name: w.name, ...(typeof w.op === "string" ? { op: w.op } : {}) } : undefined;
|
|
250
293
|
};
|
|
294
|
+
const heldByOf = (v: unknown): { hook: string; moment: string; target?: string } | undefined => {
|
|
295
|
+
const h = v as { hook?: unknown; moment?: unknown; target?: unknown } | null;
|
|
296
|
+
return h && typeof h === "object" && typeof h.hook === "string" && typeof h.moment === "string" ? { hook: h.hook, moment: h.moment, ...(typeof h.target === "string" ? { target: h.target } : {}) } : undefined;
|
|
297
|
+
};
|
|
298
|
+
const modeOf = (v: unknown): GateApprovalSource | undefined => (v === "ledger" || v === "pr-review" || v === "sealed" ? v : undefined);
|
|
251
299
|
const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
|
|
252
300
|
const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : undefined);
|
|
253
301
|
const time = (iso: string): number => Date.parse(iso) || 0;
|
|
@@ -302,6 +350,23 @@ interface Seen {
|
|
|
302
350
|
/** Whether two digests, either absent, can be the same plan: absent matches anything, as an old line records none. */
|
|
303
351
|
const compatible = (a: string | undefined, b: string | undefined): boolean => a === undefined || b === undefined || samePlanDigest(a, b);
|
|
304
352
|
|
|
353
|
+
function decisionOf(d: GateAuditDecision): WaveDecisionRecord | undefined {
|
|
354
|
+
let v: unknown = d.record;
|
|
355
|
+
if (v === undefined && d.text !== undefined) {
|
|
356
|
+
try {
|
|
357
|
+
v = JSON.parse(d.text);
|
|
358
|
+
} catch {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const r = v as Row | null;
|
|
363
|
+
if (!r || typeof r !== "object" || typeof r.wave !== "number" || typeof r.name !== "string" || typeof r.op !== "string" || typeof r.gate !== "string") return undefined;
|
|
364
|
+
const outcomes = Array.isArray(r.outcomes)
|
|
365
|
+
? r.outcomes.filter((o): o is NonNullable<WaveDecisionRecord["outcomes"]>[number] => !!o && typeof o === "object" && typeof (o as Row).moment === "string" && typeof (o as Row).name === "string" && typeof (o as Row).exit === "number" && typeof (o as Row).outcome === "string")
|
|
366
|
+
: undefined;
|
|
367
|
+
return { wave: r.wave, name: r.name, op: r.op, gate: r.gate, ...(str(r.digest) ? { digest: str(r.digest) } : {}), ...(strings(r.heldBy) ? { heldBy: strings(r.heldBy) } : {}), ...(r.heldOn !== undefined ? { heldOn: r.heldOn } : {}), ...(outcomes ? { outcomes } : {}) };
|
|
368
|
+
}
|
|
369
|
+
|
|
305
370
|
/** The entries `input` adds up to, oldest first, and the gaps they show. */
|
|
306
371
|
export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntry[]; gaps: GateAuditGap[] } {
|
|
307
372
|
const isOverride = input.isOverride ?? isOverrideGate;
|
|
@@ -359,7 +424,7 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
|
|
|
359
424
|
...base,
|
|
360
425
|
kind: override ? "override-requested" : "approval-requested",
|
|
361
426
|
at: String(r.timestamp),
|
|
362
|
-
...drop({ runId: str(r.runId), expiresAt: str(r.expiresAt), description: str(r.description), url: str(r.url), rules: strings(r.rules), wave: waveOf(r.wave) }),
|
|
427
|
+
...drop({ runId: str(r.runId), expiresAt: str(r.expiresAt), description: str(r.description), url: str(r.url), rules: strings(r.rules), wave: waveOf(r.wave), heldBy: heldByOf(r.heldBy), approvalMode: modeOf(r.approvalMode) }),
|
|
363
428
|
detail: extra,
|
|
364
429
|
}));
|
|
365
430
|
continue;
|
|
@@ -450,6 +515,35 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
|
|
|
450
515
|
}
|
|
451
516
|
}
|
|
452
517
|
|
|
518
|
+
for (const d of input.decisions ?? []) {
|
|
519
|
+
const rec = decisionOf(d);
|
|
520
|
+
if (!rec) {
|
|
521
|
+
gaps.push({ kind: "malformed-line", detail: `${d.ref} is not an op-wave decision record` });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const approval = rec.digest === undefined ? undefined : entries
|
|
525
|
+
.filter((e) => e.kind === "approved" && e.gate === rec.gate && e.op === rec.op && e.digest !== undefined && samePlanDigest(e.digest, rec.digest!))
|
|
526
|
+
.sort((x, y) => time(y.at) - time(x.at))[0];
|
|
527
|
+
const applied = rec.digest === undefined ? undefined : entries.find((e) => e.kind === "applied" && e.gate === rec.gate && e.digest !== undefined && samePlanDigest(e.digest, rec.digest!));
|
|
528
|
+
for (const [i, o] of (rec.outcomes ?? []).entries()) {
|
|
529
|
+
if (o.outcome !== "passed" && o.outcome !== "failed" && o.outcome !== "held" && o.outcome !== "skipped") continue;
|
|
530
|
+
entries.push(keyed({
|
|
531
|
+
id: idOf("hook", d.ref, d.at, i, o),
|
|
532
|
+
kind: "hook",
|
|
533
|
+
at: d.at,
|
|
534
|
+
gate: rec.gate,
|
|
535
|
+
op: rec.op,
|
|
536
|
+
...(rec.digest ? { digest: rec.digest } : {}),
|
|
537
|
+
...(approval ? { under: approval.id } : {}),
|
|
538
|
+
...(applied ? { applied: applied.id } : {}),
|
|
539
|
+
hook: { moment: o.moment, name: o.name, ...(o.target ? { target: o.target } : {}), outcome: o.outcome, exit: o.exit, ...(o.reason ? { reason: o.reason } : {}) },
|
|
540
|
+
wave: { number: rec.wave, name: rec.name, op: rec.op },
|
|
541
|
+
detail: drop({ heldBy: rec.heldBy?.length ? rec.heldBy : undefined, heldOn: o.outcome === "held" ? rec.heldOn : undefined }),
|
|
542
|
+
source: { path: d.ref, op: rec.op, change: "decision" },
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
453
547
|
entries.sort((x, y) => time(x.at) - time(y.at));
|
|
454
548
|
return { entries, gaps };
|
|
455
549
|
}
|
|
@@ -510,3 +604,22 @@ export async function readGateAuditLedgers(port: Pick<GateLedgerPort, "read">, o
|
|
|
510
604
|
}
|
|
511
605
|
return out;
|
|
512
606
|
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The thin reader for decision records kept on the ledger branch: every
|
|
610
|
+
* `.json` file directly under `dir` (such as a product's `_wave-decisions/<name>`),
|
|
611
|
+
* as input for {@link GateAuditInput.decisions}. A decision record holds no
|
|
612
|
+
* time, so `at` stamps them all: when the product collected them. Where the
|
|
613
|
+
* records live is the product's business; chant writes them to
|
|
614
|
+
* `.chant/op-waves/<name>/wave-<k>.json` in the job, and a product that
|
|
615
|
+
* collects them elsewhere passes them in directly.
|
|
616
|
+
*/
|
|
617
|
+
export async function readGateAuditDecisions(port: Pick<LedgerReadPort, "read" | "list">, dir: string, at: string): Promise<GateAuditDecision[]> {
|
|
618
|
+
const out: GateAuditDecision[] = [];
|
|
619
|
+
for (const file of (await port.list(dir)).sort()) {
|
|
620
|
+
if (!file.endsWith(".json")) continue;
|
|
621
|
+
const text = await port.read(dir, file);
|
|
622
|
+
if (text !== null) out.push({ ref: `${dir}/${file}`, at, text });
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import {
|
|
7
7
|
appendGateResolution, appendPendingGate, readGateResolutions, readGateLedger,
|
|
8
8
|
latestResolutionSince, latestPendingGate, isPendingGateExpired,
|
|
9
|
-
resolveApprovalUrl, isApprovalUrl, latestResolutionForPlan,
|
|
9
|
+
resolveApprovalUrl, isApprovalUrl, latestResolutionForPlan, parseGateLedger,
|
|
10
10
|
type PendingGateRecord, type GateResolutionRecord,
|
|
11
11
|
} from "./gate-ledger";
|
|
12
12
|
import { readBlobFromPath, writeBlobToPath } from "./git";
|
|
@@ -484,3 +484,22 @@ describe("gate ledger newline termination (#3849)", () => {
|
|
|
484
484
|
});
|
|
485
485
|
});
|
|
486
486
|
});
|
|
487
|
+
|
|
488
|
+
describe("pending fact wave and heldBy (#3886, #3887)", () => {
|
|
489
|
+
const line = (o: Record<string, unknown>) =>
|
|
490
|
+
JSON.stringify({ version: 1, kind: "pending", op: "tg", gate: "g", timestamp: "2026-10-01T00:00:00Z", expiresAt: "2026-10-02T00:00:00Z", ...o });
|
|
491
|
+
test("the reader types wave, heldBy and approvalMode", () => {
|
|
492
|
+
const { pending } = parseGateLedger(line({ approvalMode: "pr-review", wave: { number: 2, name: "prod", op: "tg" }, heldBy: { hook: "migrate", moment: "before-apply", target: "vpc" } }));
|
|
493
|
+
const p: PendingGateRecord = pending[0]!;
|
|
494
|
+
expect(p.wave).toEqual({ number: 2, name: "prod", op: "tg" });
|
|
495
|
+
expect(p.heldBy).toEqual({ hook: "migrate", moment: "before-apply", target: "vpc" });
|
|
496
|
+
expect(p.approvalMode).toBe("pr-review");
|
|
497
|
+
});
|
|
498
|
+
test("a wave or heldBy of the wrong shape is dropped, the line is kept", () => {
|
|
499
|
+
const { pending, malformed } = parseGateLedger(line({ wave: { number: "2" }, heldBy: "migrate" }));
|
|
500
|
+
expect(malformed).toBe(0);
|
|
501
|
+
expect(pending).toHaveLength(1);
|
|
502
|
+
expect(pending[0]!.wave).toBeUndefined();
|
|
503
|
+
expect(pending[0]!.heldBy).toBeUndefined();
|
|
504
|
+
});
|
|
505
|
+
});
|
|
@@ -228,6 +228,21 @@ export type GateResolutionInput = Omit<GateResolutionRecord, "version" | "kind">
|
|
|
228
228
|
* ledger by a line a minute. Once `expiresAt` passes, the fact is stale and
|
|
229
229
|
* the next run records a fresh one.
|
|
230
230
|
*/
|
|
231
|
+
export interface PendingGateWave {
|
|
232
|
+
number: number;
|
|
233
|
+
name: string;
|
|
234
|
+
/** The Op waves spec's name. */
|
|
235
|
+
op?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The hook that holds a wave on a gate (#3886): which hook asked, at which moment, and for which run. */
|
|
239
|
+
export interface PendingGateHeldBy {
|
|
240
|
+
hook: string;
|
|
241
|
+
moment: string;
|
|
242
|
+
/** The run, for a `perRun` hook. */
|
|
243
|
+
target?: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
231
246
|
export interface PendingGateRecord {
|
|
232
247
|
/** Schema version, so an incompatible future shape is detected before being misread. */
|
|
233
248
|
version: 1;
|
|
@@ -296,6 +311,17 @@ export interface PendingGateRecord {
|
|
|
296
311
|
* than inferring it from config. Not the quorum block in `approval`.
|
|
297
312
|
*/
|
|
298
313
|
approvalMode?: GateApprovalSource;
|
|
314
|
+
/**
|
|
315
|
+
* The Op wave this gate belongs to (#3839): a hold on another gate or a
|
|
316
|
+
* policy denial. Absent on a gate that is not a wave's.
|
|
317
|
+
*/
|
|
318
|
+
wave?: PendingGateWave;
|
|
319
|
+
/**
|
|
320
|
+
* The hook that holds the wave on this gate (#3886), on a hold only: its
|
|
321
|
+
* name, the moment it ran at and its run. A reader names the holder from it
|
|
322
|
+
* rather than from `description`, which the hook writes.
|
|
323
|
+
*/
|
|
324
|
+
heldBy?: PendingGateHeldBy;
|
|
299
325
|
/**
|
|
300
326
|
* The open question this gate waits on, on a gate that asks a decision
|
|
301
327
|
* point (#3170). It is answered with `chant workspace points answer <id>`,
|
|
@@ -449,7 +475,13 @@ export function parseGateLedger(
|
|
|
449
475
|
malformed++;
|
|
450
476
|
continue;
|
|
451
477
|
}
|
|
452
|
-
|
|
478
|
+
// #3887: a `wave` or `heldBy` of the wrong shape is dropped, so a reader can trust the type.
|
|
479
|
+
const rec = parsed as PendingGateRecord;
|
|
480
|
+
const w = (parsed as { wave?: unknown }).wave as Partial<PendingGateWave> | null | undefined;
|
|
481
|
+
if (w !== undefined && !(w && typeof w === "object" && typeof w.number === "number" && typeof w.name === "string")) delete rec.wave;
|
|
482
|
+
const h = (parsed as { heldBy?: unknown }).heldBy as Partial<PendingGateHeldBy> | null | undefined;
|
|
483
|
+
if (h !== undefined && !(h && typeof h === "object" && typeof h.hook === "string" && typeof h.moment === "string")) delete rec.heldBy;
|
|
484
|
+
pending.push(rec);
|
|
453
485
|
continue;
|
|
454
486
|
}
|
|
455
487
|
if (typeof parsed.resolvedBy !== "string") {
|
|
@@ -271,7 +271,7 @@ export const lifecyclePlanChangeSetContract = activityContract(
|
|
|
271
271
|
|
|
272
272
|
export const readChangeSetPartContract = activityContract(
|
|
273
273
|
"readChangeSetPart",
|
|
274
|
-
z.strictObject({ member: z.string(), planner: z.enum(["chant", "warden"]), file: z.string(), lexicon: z.string().optional() }),
|
|
274
|
+
z.strictObject({ member: z.string(), planner: z.enum(["chant", "warden", "sql"]), file: z.string(), lexicon: z.string().optional() }),
|
|
275
275
|
z.object({ part: changeSetPart }),
|
|
276
276
|
);
|
|
277
277
|
|
|
@@ -25,6 +25,7 @@ import { promisify } from "node:util";
|
|
|
25
25
|
import {
|
|
26
26
|
composeChangeSet as compose,
|
|
27
27
|
lifecyclePlanPart,
|
|
28
|
+
readChangeSetPartFile,
|
|
28
29
|
reconcilePlanPart,
|
|
29
30
|
type ChangeSetDocument,
|
|
30
31
|
type ChangeSetPart,
|
|
@@ -87,8 +88,12 @@ export async function lifecyclePlanChangeSet(args: LifecyclePlanChangeSetArgs, s
|
|
|
87
88
|
|
|
88
89
|
export interface ReadChangeSetPartArgs {
|
|
89
90
|
member: string;
|
|
90
|
-
/**
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Which planner wrote the file: `chant` (`lifecycle plan --json`), `warden`
|
|
93
|
+
* (its reconcile change sets), or `sql` (a part the sql lexicon's
|
|
94
|
+
* `change-set` adapters produced, written as JSON).
|
|
95
|
+
*/
|
|
96
|
+
planner: "chant" | "warden" | "sql";
|
|
92
97
|
/** The plan file, relative to the current directory. */
|
|
93
98
|
file: string;
|
|
94
99
|
/** The lexicon the entries belong to, when the plan does not say. */
|
|
@@ -98,7 +103,8 @@ export interface ReadChangeSetPartArgs {
|
|
|
98
103
|
/**
|
|
99
104
|
* A plan another step or job wrote to a file, as a member's part. A warden
|
|
100
105
|
* plan is the reconcile `ChangeSet` (`@intentius/chant/reconcile`), or an
|
|
101
|
-
* array of them, one per cycle and org.
|
|
106
|
+
* array of them, one per cycle and org. A sql plan is already a part
|
|
107
|
+
* (`@intentius/chant-lexicon-sql/change-set`), renamed to `member` here.
|
|
102
108
|
*/
|
|
103
109
|
export async function readChangeSetPart(args: ReadChangeSetPartArgs): Promise<ChangeSetPartResult> {
|
|
104
110
|
const plan: unknown = JSON.parse(readFileSync(resolve(args.file), "utf-8"));
|
|
@@ -108,6 +114,8 @@ export async function readChangeSetPart(args: ReadChangeSetPartArgs): Promise<Ch
|
|
|
108
114
|
return { part: lifecyclePlanPart({ member: args.member, plan: plan as LifecycleChangeSet, ...lexicon }) };
|
|
109
115
|
case "warden":
|
|
110
116
|
return { part: reconcilePlanPart({ member: args.member, plan: plan as ReconcileChangeSet | ReconcileChangeSet[], ...lexicon }) };
|
|
117
|
+
case "sql":
|
|
118
|
+
return { part: readChangeSetPartFile(plan, "sql", args.member, args.lexicon) };
|
|
111
119
|
default:
|
|
112
120
|
throw new Error(`readChangeSetPart: no adapter for planner ${String(args.planner)}; terraform-family plans come from terraformPlan's changeSet`);
|
|
113
121
|
}
|
|
@@ -317,6 +317,8 @@ describe("lexiconUpgrade issue mode", () => {
|
|
|
317
317
|
|
|
318
318
|
vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
|
|
319
319
|
vi.stubEnv("GITHUB_API_URL", "");
|
|
320
|
+
// A Forgejo run, since CHANT_FORGEJO_TOKEN is only read there (#3807).
|
|
321
|
+
vi.stubEnv("FORGEJO_ACTIONS", "true");
|
|
320
322
|
// The cross-instance case (#2320): a Forgejo token that must outrank the
|
|
321
323
|
// job's own `github.token`, on the path that used to forward neither.
|
|
322
324
|
vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ghCredentialEnv,
|
|
22
22
|
githubShapedForgeFrom,
|
|
23
23
|
} from "./reconcile";
|
|
24
|
+
import { FORGE_TOKEN_ORDER } from "../../pr-forge";
|
|
24
25
|
|
|
25
26
|
// ── The `gh` stub (chant #2291) ──────────────────────────────────────────────
|
|
26
27
|
//
|
|
@@ -338,12 +339,46 @@ describe("commentTokenFrom (#2291)", () => {
|
|
|
338
339
|
expect(commentTokenFrom({ GITHUB_TOKEN: "ghs_y" })).toEqual({ value: "ghs_y", source: "GITHUB_TOKEN" });
|
|
339
340
|
});
|
|
340
341
|
|
|
341
|
-
test("CHANT_FORGEJO_TOKEN wins
|
|
342
|
+
test("on Forgejo CHANT_FORGEJO_TOKEN wins, then FORGEJO_TOKEN, then GH_TOKEN, then GITHUB_TOKEN", () => {
|
|
343
|
+
const base = { FORGEJO_ACTIONS: "true" };
|
|
344
|
+
const all = { CHANT_FORGEJO_TOKEN: "a", FORGEJO_TOKEN: "b", GH_TOKEN: "c", GITHUB_TOKEN: "d" };
|
|
345
|
+
expect(commentTokenFrom({ ...base, ...all })?.source).toBe("CHANT_FORGEJO_TOKEN");
|
|
346
|
+
expect(commentTokenFrom({ ...base, FORGEJO_TOKEN: "b", GH_TOKEN: "c", GITHUB_TOKEN: "d" })?.source).toBe("FORGEJO_TOKEN");
|
|
347
|
+
expect(commentTokenFrom({ ...base, GH_TOKEN: "c", GITHUB_TOKEN: "d" })?.source).toBe("GH_TOKEN");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a /api/v1 API base is Forgejo even without FORGEJO_ACTIONS", () => {
|
|
342
351
|
expect(
|
|
343
|
-
commentTokenFrom({
|
|
352
|
+
commentTokenFrom({ GITHUB_API_URL: "https://f.example/api/v1", CHANT_FORGEJO_TOKEN: "a", GH_TOKEN: "b" })?.source,
|
|
344
353
|
).toBe("CHANT_FORGEJO_TOKEN");
|
|
345
354
|
});
|
|
346
355
|
|
|
356
|
+
test("on GitHub CHANT_GITHUB_TOKEN wins, then GH_TOKEN, then GITHUB_TOKEN (#3807)", () => {
|
|
357
|
+
const base = { GITHUB_ACTIONS: "true" };
|
|
358
|
+
expect(commentTokenFrom({ ...base, CHANT_GITHUB_TOKEN: "a", GH_TOKEN: "b", GITHUB_TOKEN: "c" })?.source).toBe("CHANT_GITHUB_TOKEN");
|
|
359
|
+
expect(commentTokenFrom({ ...base, GH_TOKEN: "b", GITHUB_TOKEN: "c" })?.source).toBe("GH_TOKEN");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("on GitHub CHANT_FORGEJO_TOKEN and FORGEJO_TOKEN are not read (#3807)", () => {
|
|
363
|
+
expect(
|
|
364
|
+
commentTokenFrom({ GITHUB_ACTIONS: "true", CHANT_FORGEJO_TOKEN: "a", FORGEJO_TOKEN: "b", GH_TOKEN: "c" }),
|
|
365
|
+
).toEqual({ value: "c", source: "GH_TOKEN" });
|
|
366
|
+
expect(commentTokenFrom({ GITHUB_ACTIONS: "true", CHANT_FORGEJO_TOKEN: "a" })).toBeUndefined();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("agrees with FORGE_TOKEN_ORDER for each forge", () => {
|
|
370
|
+
for (const [kind, env] of [
|
|
371
|
+
["github", { GITHUB_ACTIONS: "true" }],
|
|
372
|
+
["forgejo", { FORGEJO_ACTIONS: "true" }],
|
|
373
|
+
] as const) {
|
|
374
|
+
for (const name of FORGE_TOKEN_ORDER[kind]) {
|
|
375
|
+
const rest = FORGE_TOKEN_ORDER[kind].slice(FORGE_TOKEN_ORDER[kind].indexOf(name));
|
|
376
|
+
const vars = Object.fromEntries(rest.map((n) => [n, `v-${n}`]));
|
|
377
|
+
expect(commentTokenFrom({ ...env, ...vars })?.source).toBe(name);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
347
382
|
test("no token at all is undefined rather than an empty value", () => {
|
|
348
383
|
expect(commentTokenFrom({})).toBeUndefined();
|
|
349
384
|
expect(commentTokenFrom({ GH_TOKEN: "", GITHUB_TOKEN: "" })).toBeUndefined();
|
|
@@ -469,6 +504,31 @@ describe("reconcilePr comment mode posts through the forge client (#2291, #3757)
|
|
|
469
504
|
}
|
|
470
505
|
});
|
|
471
506
|
|
|
507
|
+
test("on a GitHub run CHANT_FORGEJO_TOKEN is ignored and GH_TOKEN is sent (#3807)", async () => {
|
|
508
|
+
stubPrEnv("https://api.github.com");
|
|
509
|
+
vi.stubEnv("GITHUB_ACTIONS", "true");
|
|
510
|
+
vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-token-set-on-github");
|
|
511
|
+
try {
|
|
512
|
+
await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
|
|
513
|
+
expect(forgeCalls.length).toBeGreaterThan(0);
|
|
514
|
+
for (const call of forgeCalls) expect(call.authorization).toBe("Bearer forgejo-actions-token");
|
|
515
|
+
} finally {
|
|
516
|
+
vi.unstubAllEnvs();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("on a GitHub run CHANT_GITHUB_TOKEN outranks GH_TOKEN (#3807)", async () => {
|
|
521
|
+
stubPrEnv("https://api.github.com");
|
|
522
|
+
vi.stubEnv("GITHUB_ACTIONS", "true");
|
|
523
|
+
vi.stubEnv("CHANT_GITHUB_TOKEN", "other-github-token");
|
|
524
|
+
try {
|
|
525
|
+
await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
|
|
526
|
+
for (const call of forgeCalls) expect(call.authorization).toBe("Bearer other-github-token");
|
|
527
|
+
} finally {
|
|
528
|
+
vi.unstubAllEnvs();
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
472
532
|
test("FORGEJO_ACTIONS marks a Forgejo run whatever its API base looks like", async () => {
|
|
473
533
|
stubPrEnv("https://code.example/api/forgejo");
|
|
474
534
|
vi.stubEnv("FORGEJO_ACTIONS", "true");
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
detectForge,
|
|
6
|
+
FORGE_TOKEN_ORDER,
|
|
7
|
+
ForgeApiError,
|
|
8
|
+
forgejoForge,
|
|
9
|
+
githubForge,
|
|
10
|
+
type ForgeFetch,
|
|
11
|
+
type GithubShapedForge,
|
|
12
|
+
} from "../../pr-forge";
|
|
5
13
|
import { ghHostEnv } from "./gh-host";
|
|
6
14
|
import { restPullRequestForge } from "./forge-pull-request";
|
|
7
15
|
|
|
@@ -49,9 +57,11 @@ const execAsync = promisify(exec);
|
|
|
49
57
|
* of opening a second one.
|
|
50
58
|
*
|
|
51
59
|
* Both modes resolve the GitHub/Forgejo credential the same way, through
|
|
52
|
-
* {@link commentTokenFrom}
|
|
53
|
-
*
|
|
54
|
-
*
|
|
60
|
+
* {@link commentTokenFrom}, in pr-forge's `FORGE_TOKEN_ORDER` for the forge the
|
|
61
|
+
* run is on (chant #2320, #3807): `CHANT_GITHUB_TOKEN`, `GH_TOKEN`,
|
|
62
|
+
* `GITHUB_TOKEN` on GitHub; `CHANT_FORGEJO_TOKEN`, `FORGEJO_TOKEN`,
|
|
63
|
+
* `GH_TOKEN`, `GITHUB_TOKEN` on Forgejo. It refuses by name before any call
|
|
64
|
+
* when none is set ({@link noCommentTokenMessage}, {@link noIssueTokenMessage}).
|
|
55
65
|
*
|
|
56
66
|
* Until #3757 these two modes shelled to `gh api`. That reached Forgejo only
|
|
57
67
|
* after two fixes: a full URL, because `gh` resolves a relative path against
|
|
@@ -451,24 +461,38 @@ export interface CommentToken {
|
|
|
451
461
|
source: string;
|
|
452
462
|
}
|
|
453
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Which GitHub-shaped forge a run is on: Forgejo when `detectForge` says so
|
|
466
|
+
* (`FORGEJO_ACTIONS`/`GITEA_ACTIONS`, or a `GITHUB_API_URL` ending in
|
|
467
|
+
* `/api/v1`), or when the API base ends in `/api/v1`, which is how a step
|
|
468
|
+
* posting to some other Forgejo instance names it. GitHub serves `/api/v3`
|
|
469
|
+
* or `api.github.com`, never `/api/v1`. Anything else is GitHub.
|
|
470
|
+
*/
|
|
471
|
+
export function githubShapedKindFrom(env: Record<string, string | undefined>): "github" | "forgejo" {
|
|
472
|
+
if (detectForge(env) === "forgejo") return "forgejo";
|
|
473
|
+
return /\/api\/v1$/.test(githubApiBaseFrom(env)) ? "forgejo" : "github";
|
|
474
|
+
}
|
|
475
|
+
|
|
454
476
|
/**
|
|
455
477
|
* Resolve the token the `comment` and `issue` modes post with on GitHub and
|
|
456
|
-
* Forgejo
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
478
|
+
* Forgejo (chant #2291), in pr-forge's `FORGE_TOKEN_ORDER` for the forge the
|
|
479
|
+
* run is on (#3807), so this and `forgeTargetFromEnv` cannot drift apart.
|
|
480
|
+
* GitHub reads `CHANT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`; Forgejo reads
|
|
481
|
+
* `CHANT_FORGEJO_TOKEN`, `FORGEJO_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`. The
|
|
482
|
+
* generated workflow sets `GH_TOKEN` and `GITHUB_TOKEN` to
|
|
483
|
+
* `${{ github.token }}` for every non-`report` finding mode, on Forgejo the
|
|
484
|
+
* same way as on GitHub, so the common case needs nothing set beyond what the
|
|
485
|
+
* generator already emits.
|
|
463
486
|
*
|
|
464
|
-
*
|
|
465
|
-
* cannot cover: a run posting to
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
* of the forge's own
|
|
487
|
+
* The chant-specific variable comes first for the case that ambient token
|
|
488
|
+
* cannot cover: a run posting to an instance other than the one the job
|
|
489
|
+
* executes on, where `github.token`'s scope stops at its own instance.
|
|
490
|
+
* `CHANT_FORGEJO_TOKEN` is no longer read on a GitHub run. A run with none of
|
|
491
|
+
* the names is refused by name before any call, in place of the forge's own
|
|
492
|
+
* opaque 401.
|
|
469
493
|
*/
|
|
470
494
|
export function commentTokenFrom(env: Record<string, string | undefined>): CommentToken | undefined {
|
|
471
|
-
for (const source of [
|
|
495
|
+
for (const source of FORGE_TOKEN_ORDER[githubShapedKindFrom(env)]) {
|
|
472
496
|
const value = env[source]?.trim();
|
|
473
497
|
if (value) return { value, source };
|
|
474
498
|
}
|
|
@@ -543,8 +567,8 @@ export function noCommentTokenMessage(repo: string, number: number): string {
|
|
|
543
567
|
return (
|
|
544
568
|
`reconcilePr mode "comment" has pull request ${repo}#${number} to post its finding on and no token to ` +
|
|
545
569
|
"post it with. GH_TOKEN or GITHUB_TOKEN, set from github.token, already covers this on a GitHub Actions " +
|
|
546
|
-
"or Forgejo Actions run — set
|
|
547
|
-
"job runs on.
|
|
570
|
+
"or Forgejo Actions run — set CHANT_GITHUB_TOKEN (GitHub) or CHANT_FORGEJO_TOKEN (Forgejo) to post " +
|
|
571
|
+
"against a different instance than the one the job runs on. It is read first where the two must differ."
|
|
548
572
|
);
|
|
549
573
|
}
|
|
550
574
|
|
|
@@ -553,8 +577,8 @@ export function noIssueTokenMessage(repo: string): string {
|
|
|
553
577
|
return (
|
|
554
578
|
`reconcilePr mode "issue" has repository ${repo} to open or edit its finding in and no token to do it ` +
|
|
555
579
|
"with. GH_TOKEN or GITHUB_TOKEN, set from github.token, already covers this on a GitHub Actions or " +
|
|
556
|
-
"Forgejo Actions run — set
|
|
557
|
-
"job runs on.
|
|
580
|
+
"Forgejo Actions run — set CHANT_GITHUB_TOKEN (GitHub) or CHANT_FORGEJO_TOKEN (Forgejo) to post " +
|
|
581
|
+
"against a different instance than the one the job runs on. It is read first where the two must differ."
|
|
558
582
|
);
|
|
559
583
|
}
|
|
560
584
|
|
|
@@ -582,7 +606,7 @@ export function githubShapedForgeFrom(
|
|
|
582
606
|
signal?: AbortSignal,
|
|
583
607
|
): GithubShapedForge {
|
|
584
608
|
const apiBase = githubApiBaseFrom(env);
|
|
585
|
-
const forgejo = env
|
|
609
|
+
const forgejo = githubShapedKindFrom(env) === "forgejo";
|
|
586
610
|
// Read at call time, so a test that stubs the global sees its stub.
|
|
587
611
|
const fetchImpl: ForgeFetch = (url, init) => globalThis.fetch(url, { ...init, ...(signal ? { signal } : {}) });
|
|
588
612
|
const opts = { apiBase, repo, token: token.value, fetch: fetchImpl };
|
package/src/op/builders.ts
CHANGED
|
@@ -428,7 +428,7 @@ export const lifecyclePlanChangeSet = (args: WithStepRefs<LifecyclePlanChangeSet
|
|
|
428
428
|
return activity("lifecyclePlanChangeSet", rest, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
|
|
429
429
|
};
|
|
430
430
|
|
|
431
|
-
/** A member's change-set part from a plan file a chant or
|
|
431
|
+
/** A member's change-set part from a plan file a chant, warden or sql run wrote (#3181, #3767). Its `part` feeds {@link composeChangeSet}. */
|
|
432
432
|
export const readChangeSetPart = (args: WithStepRefs<ReadChangeSetPartArgs> & StepOpts): NamedActivityStep => {
|
|
433
433
|
const { args: rest, profile, id } = takeProfileAndId(args as Record<string, unknown>);
|
|
434
434
|
return activity("readChangeSetPart", rest, { profile: profile ?? "fastIdempotent", ...(id ? { id } : {}) });
|