@intentius/chant 0.124.4 → 0.124.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +32 -1
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +7 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/op/gate-point-run.d.ts +1 -1
  7. package/dist/op/gate-point-run.d.ts.map +1 -1
  8. package/dist/op/gate-point.d.ts +6 -0
  9. package/dist/op/gate-point.d.ts.map +1 -1
  10. package/dist/op/gate-resume.d.ts +2 -31
  11. package/dist/op/gate-resume.d.ts.map +1 -1
  12. package/dist/op/gate-run-locator.d.ts +40 -0
  13. package/dist/op/gate-run-locator.d.ts.map +1 -0
  14. package/dist/op/index.d.ts +2 -2
  15. package/dist/op/index.d.ts.map +1 -1
  16. package/dist/op/op-waves-notify.d.ts +3 -1
  17. package/dist/op/op-waves-notify.d.ts.map +1 -1
  18. package/dist/op/op-waves-run.d.ts +58 -3
  19. package/dist/op/op-waves-run.d.ts.map +1 -1
  20. package/dist/op/op-waves.d.ts +16 -0
  21. package/dist/op/op-waves.d.ts.map +1 -1
  22. package/dist/point-decision.d.ts +63 -1
  23. package/dist/point-decision.d.ts.map +1 -1
  24. package/dist/workspace/points-cli.d.ts.map +1 -1
  25. package/dist/workspace/records-cli.d.ts +15 -0
  26. package/dist/workspace/records-cli.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cli/handlers/gate-resume.ts +84 -11
  29. package/src/cli/main.ts +4 -2
  30. package/src/lifecycle/gate-ledger.ts +7 -0
  31. package/src/op/gate-point-ci.e2e.test.ts +39 -2
  32. package/src/op/gate-point-resume.e2e.test.ts +203 -0
  33. package/src/op/gate-point-run.ts +3 -0
  34. package/src/op/gate-point.ts +11 -0
  35. package/src/op/gate-resume.ts +5 -95
  36. package/src/op/gate-run-locator.ts +105 -0
  37. package/src/op/index.ts +3 -0
  38. package/src/op/op-waves-notify.ts +16 -1
  39. package/src/op/op-waves-run.ts +221 -23
  40. package/src/op/op-waves.test.ts +135 -0
  41. package/src/op/op-waves.ts +33 -1
  42. package/src/point-decision-bundle.test.ts +1 -0
  43. package/src/point-decision.ts +97 -1
  44. package/src/workspace/points-cli.ts +7 -2
  45. package/src/workspace/records-cli.ts +38 -7
  46. package/src/workspace/records.schema.json +4 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * #3835: an answered decision point resumes the job waiting on it. A gate
3
+ * that asks a point writes its resolution only when the run asks again, so
4
+ * `chant run resume` used to see no approval and leave the job stopped. Now a
5
+ * pending fact that cites a question is resumable once the question has an
6
+ * answer the gate passes on, and `points answer --resume` resumes right after
7
+ * the answer.
8
+ *
9
+ * Clones of one bare remote stand for the CI job's checkout, the person's
10
+ * clone the answer comes from, the scheduled resume job's checkout and the
11
+ * resumed job's. The gate ledger is the real one on `chant/lifecycle`, so the
12
+ * pending fact, the question and its answer all travel through the remote.
13
+ * The forge is a stubbed `fetch` that answers as GitHub does for a failed run.
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { mkdtempSync, realpathSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
21
+ import { cleanScratch, workspace } from "../workspace/__fixtures__/decide-workspace";
22
+ import { runWorkspacePoints } from "../workspace/points-cli";
23
+ import { runResumeCommand } from "../cli/handlers/gate-resume";
24
+ import type { CommandContext } from "../cli/registry";
25
+ import { readGateLedger } from "../lifecycle/gate-ledger";
26
+ import { pointGateResumable } from "../point-decision";
27
+ import { ApplyOp } from "./composites/apply-op";
28
+ import { gitGateLedgerPort } from "./gate";
29
+ import type { GatePoint } from "./gate-point";
30
+ import { runOpLocally } from "./local-executor";
31
+ import type { ActivityFn } from "./activity-registry";
32
+ import type { OpConfig } from "./types";
33
+
34
+ const PLAN = `jcs1-sha256:${"6".repeat(64)}`;
35
+
36
+ const POINTS = {
37
+ points: {
38
+ "prod-apply": {
39
+ title: "Apply this plan to prod",
40
+ question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
41
+ inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
42
+ deciders: [{ kind: "quorum", count: 1 }],
43
+ },
44
+ },
45
+ };
46
+
47
+ const GITHUB = { GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "acme/infra", GITHUB_RUN_ID: "42", GITHUB_RUN_ATTEMPT: "1", GITHUB_API_URL: "https://api.github.test" };
48
+
49
+ const applied: string[] = [];
50
+ const activities = new Map<string, ActivityFn>([
51
+ ["chantBuild", async () => ({ ok: true })],
52
+ ["lifecycleDiff", async () => ({ planDigest: PLAN, drifted: true })],
53
+ ["nativeApply", async () => {
54
+ applied.push(PLAN);
55
+ return { ok: true };
56
+ }],
57
+ ]);
58
+
59
+ const config = (name: string, point: GatePoint): OpConfig =>
60
+ (ApplyOp({ name, env: "prod", target: "kubectl", gate: { gate: "approve-apply", point } }).op as unknown as { props: OpConfig }).props;
61
+
62
+ let home: string;
63
+ let remote: string;
64
+ let n = 0;
65
+
66
+ function clone(): string {
67
+ const dir = join(home, `clone-${++n}`);
68
+ execFileSync("git", ["clone", "-q", remote, dir], { stdio: "ignore" });
69
+ return dir;
70
+ }
71
+
72
+ /** A wave's run in `cwd`, on the real gate ledger. */
73
+ const run = (op: OpConfig, cwd: string) => runOpLocally(op, activities, {}, undefined, { gates: gitGateLedgerPort({ cwd }), cwd, now: new Date().toISOString() });
74
+
75
+ /** Run a command handler in `cwd`; returns what it printed on standard output, parsed, and its exit code. */
76
+ async function inDir<T>(cwd: string, handler: (ctx: CommandContext) => Promise<number>, args: Record<string, unknown>): Promise<{ code: number; doc: T }> {
77
+ const printed: string[] = [];
78
+ const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
79
+ printed.push(String(line));
80
+ });
81
+ const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
82
+ const was = process.cwd();
83
+ process.chdir(cwd);
84
+ try {
85
+ const code = await handler({ args, plugins: [], serializers: [] } as unknown as CommandContext);
86
+ return { code, doc: JSON.parse(printed.join("\n")) as T };
87
+ } finally {
88
+ process.chdir(was);
89
+ log.mockRestore();
90
+ err.mockRestore();
91
+ }
92
+ }
93
+
94
+ /** The forge: a GitHub run that ended in failure (the job exited 3 at the gate). Records each call. */
95
+ let calls: string[];
96
+ function stubForge(): void {
97
+ calls = [];
98
+ vi.stubGlobal("fetch", async (url: string, init: { method: string }) => {
99
+ calls.push(`${init.method} ${new URL(url).pathname}`);
100
+ const body = init.method === "GET" ? JSON.stringify({ status: "completed", conclusion: "failure", run_attempt: 1 }) : "";
101
+ return { ok: true, status: 200, text: async () => body };
102
+ });
103
+ }
104
+
105
+ type Report = { op: string; gate: string; status: string; message: string; approvedBy?: string };
106
+
107
+ /** The job waits at the gate in CI; returns the question's id. */
108
+ async function waitInCi(op: OpConfig): Promise<string> {
109
+ for (const [k, v] of Object.entries(GITHUB)) vi.stubEnv(k, v);
110
+ const first = await run(op, clone());
111
+ expect(first.status).toBe("waiting");
112
+ for (const k of Object.keys(GITHUB)) vi.stubEnv(k, "");
113
+ return first.point!.id;
114
+ }
115
+
116
+ const answer = (id: string, value: string, extra: Record<string, unknown> = {}) =>
117
+ inDir<Record<string, unknown>>(clone(), runWorkspacePoints, { extraPositional: "answer", extraPositional2: id, answer: value, bys: ["alice"], ...extra });
118
+
119
+ beforeAll(() => {
120
+ home = realpathSync(mkdtempSync(join(tmpdir(), "chant-gate-point-resume-")));
121
+ const root = workspace(POINTS);
122
+ remote = join(home, "remote.git");
123
+ execFileSync("git", ["clone", "-q", "--bare", root, remote], { stdio: "ignore" });
124
+ });
125
+
126
+ beforeEach(() => {
127
+ vi.stubEnv("CI", "");
128
+ vi.stubEnv("GITHUB_TOKEN", "t");
129
+ stubForge();
130
+ });
131
+
132
+ afterEach(() => {
133
+ vi.unstubAllEnvs();
134
+ vi.unstubAllGlobals();
135
+ });
136
+
137
+ afterAll(() => {
138
+ cleanScratch();
139
+ rmSync(home, { recursive: true, force: true });
140
+ });
141
+
142
+ describe("an answered decision point resumes the wave waiting on it (#3835)", () => {
143
+ test("the wave waits on its point; the point is answered; chant run resume resumes it, and the resumed run passes", async () => {
144
+ const op = config("resume-apply", { name: "prod-apply", store: "ledger" });
145
+ const id = await waitInCi(op);
146
+
147
+ // Before the answer, nothing is resumed.
148
+ const before = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "resume-apply", json: true });
149
+ expect(before.doc.reports).toEqual([expect.objectContaining({ gate: "approve-apply", status: "not-approved", message: expect.stringContaining("not answered yet") })]);
150
+ expect(calls).toEqual([]);
151
+
152
+ expect((await answer(id, "yes")).doc).toMatchObject({ written: true, question: { state: "answered", answer: true } });
153
+
154
+ // The scheduled resume job, in its own checkout.
155
+ const resumed = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "resume-apply", json: true });
156
+ expect(resumed.code).toBe(0);
157
+ expect(resumed.doc.reports).toEqual([expect.objectContaining({ gate: "approve-apply", status: "resumed", approvedBy: "alice" })]);
158
+ expect(calls).toEqual(["GET /repos/acme/infra/actions/runs/42", "POST /repos/acme/infra/actions/runs/42/rerun-failed-jobs"]);
159
+
160
+ // The resumed job asks again, finds the answer, writes the resolution that cites it, and applies.
161
+ const job = clone();
162
+ const next = await run(op, job);
163
+ expect(next.status).toBe("ok");
164
+ expect(applied).toContain(PLAN);
165
+ const ledger = await readGateLedger("resume-apply", { cwd: job });
166
+ expect(ledger.resolutions).toEqual([expect.objectContaining({ gate: "approve-apply", resolvedBy: "alice", answer: expect.objectContaining({ id, answer: true }) })]);
167
+ }, 120_000);
168
+
169
+ test("an answer that does not pass the gate resumes nothing", async () => {
170
+ const op = config("refuse-apply", { name: "prod-apply", store: "ledger" });
171
+ const id = await waitInCi(op);
172
+ await answer(id, "no");
173
+
174
+ const resumed = await inDir<{ reports: Report[] }>(clone(), runResumeCommand, { op: "refuse-apply", json: true });
175
+ expect(resumed.code).toBe(0);
176
+ expect(resumed.doc.reports).toEqual([expect.objectContaining({ status: "not-approved", message: expect.stringContaining("passes only on true") })]);
177
+ expect(calls).toEqual([]);
178
+ }, 120_000);
179
+
180
+ test("a gate's own pass list is on the pending fact: an answer it names resumes, others don't", async () => {
181
+ const op = config("pass-apply", { name: "prod-apply", store: "ledger", pass: [false] });
182
+ const id = await waitInCi(op);
183
+ const { pending } = await gitGateLedgerPort({ cwd: clone() }).read("pass-apply");
184
+ expect(pending[0]!.answer).toMatchObject({ id, pass: [false] });
185
+ expect(pending[0]!.resume).toMatchObject({ forge: "github", run: "42" });
186
+
187
+ const answered = { id, state: "answered" as const, answeredBy: ["alice"], answeredOn: null };
188
+ expect(pointGateResumable(pending[0]!, { ...answered, answer: false })).toMatchObject({ resumable: true, answeredBy: "alice" });
189
+ expect(pointGateResumable(pending[0]!, { ...answered, answer: true })).toMatchObject({ resumable: false, why: "refused" });
190
+ expect(pointGateResumable(pending[0]!, { ...answered, state: "escalated", answer: null })).toMatchObject({ resumable: false, why: "open" });
191
+ expect(pointGateResumable(pending[0]!, undefined)).toMatchObject({ resumable: false, why: "unknown" });
192
+ }, 120_000);
193
+
194
+ test("points answer --resume resumes the job waiting at the gate the question names", async () => {
195
+ const op = config("answer-resume-apply", { name: "prod-apply", store: "ledger" });
196
+ const id = await waitInCi(op);
197
+
198
+ const answered = await answer(id, "yes", { resumeRun: true });
199
+ expect(answered.code).toBe(0);
200
+ expect(answered.doc).toMatchObject({ written: true, question: { state: "answered", subject: "gate:answer-resume-apply/approve-apply" } });
201
+ expect(calls).toEqual(["GET /repos/acme/infra/actions/runs/42", "POST /repos/acme/infra/actions/runs/42/rerun-failed-jobs"]);
202
+ }, 120_000);
203
+ });
@@ -32,7 +32,10 @@ import type { GatePointAsker } from "../point-decision";
32
32
  // The decision itself bundles without the workspace (#3816): `@intentius/chant/point-decision`.
33
33
  export {
34
34
  evaluatePointGate,
35
+ pointGateResumable,
35
36
  type GatePointAsker,
37
+ type PointQuestionState,
38
+ type PointResumeCheck,
36
39
  type GatePointQuestion,
37
40
  type GatePointRequest,
38
41
  type PointGateCheck,
@@ -141,3 +141,14 @@ export function gatePointInputs(point: string, declared: readonly string[], fact
141
141
  export function gateSubject(op: string, gate: string, env?: string | null): string {
142
142
  return `gate:${op}/${gate}${env ? `@${env}` : ""}`;
143
143
  }
144
+
145
+ /** The gate a {@link gateSubject} names, or undefined for a subject that names none (#3835). */
146
+ export function parseGateSubject(subject: string | null | undefined): { op: string; gate: string; env?: string } | undefined {
147
+ if (!subject || !subject.startsWith("gate:")) return undefined;
148
+ const rest = subject.slice("gate:".length);
149
+ const at = rest.lastIndexOf("@");
150
+ const named = at > 0 ? rest.slice(0, at) : rest;
151
+ const slash = named.indexOf("/");
152
+ if (slash <= 0 || slash === named.length - 1) return undefined;
153
+ return { op: named.slice(0, slash), gate: named.slice(slash + 1), ...(at > 0 ? { env: rest.slice(at + 1) } : {}) };
154
+ }
@@ -36,102 +36,10 @@ import { ForgeApiError, type ForgeFetch, type ForgeKind } from "../pr-forge";
36
36
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
37
37
  import { latestResolutionForPlan } from "../lifecycle/gate-ledger";
38
38
  import { humanQuorumSince, tallyGateApprovals } from "./gate";
39
+ import type { GateRunLocator } from "./gate-run-locator";
39
40
 
40
- /** Where the job that recorded a pending fact runs, and what its forge needs to start it again. */
41
- export interface GateRunLocator {
42
- forge: ForgeKind;
43
- /** The forge's API base: `$GITHUB_API_URL` (GitHub and Forgejo) or `$CI_API_V4_URL` (GitLab). */
44
- api: string;
45
- /** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
46
- repo: string;
47
- /** The run: GitHub's and Forgejo's run id, GitLab's pipeline id. */
48
- run: string;
49
- /** GitHub: the run attempt that waited. A later attempt means it was already re-run. */
50
- attempt?: number;
51
- /** GitLab: the job that waited. */
52
- job?: string;
53
- /** GitLab: that job's name, to find a retry of it. */
54
- jobName?: string;
55
- /** Forgejo: the workflow file the run came from, which a dispatch names. */
56
- workflow?: string;
57
- /** Forgejo: the branch a dispatch runs on. */
58
- ref?: string;
59
- /** The run's page, for a person. */
60
- url?: string;
61
- }
62
-
63
- const trimSlash = (s: string): string => s.replace(/\/+$/, "");
64
-
65
- /**
66
- * The locator of the CI job this process runs in, or undefined outside CI or
67
- * when the environment lacks a part a resume needs. Forgejo Actions sets the
68
- * `GITHUB_*` variables too, and is told apart by `FORGEJO_ACTIONS` or
69
- * `GITEA_ACTIONS`.
70
- */
71
- export function resolveGateRunLocator(env: Record<string, string | undefined> = process.env): GateRunLocator | undefined {
72
- if (env.GITLAB_CI === "true") {
73
- if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !env.CI_PIPELINE_ID || !env.CI_JOB_ID) return undefined;
74
- return {
75
- forge: "gitlab",
76
- api: trimSlash(env.CI_API_V4_URL),
77
- repo: env.CI_PROJECT_ID,
78
- run: env.CI_PIPELINE_ID,
79
- job: env.CI_JOB_ID,
80
- ...(env.CI_JOB_NAME ? { jobName: env.CI_JOB_NAME } : {}),
81
- ...(env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}),
82
- };
83
- }
84
- if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_REPOSITORY || !env.GITHUB_RUN_ID) return undefined;
85
- const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
86
- const server = env.GITHUB_SERVER_URL ? trimSlash(env.GITHUB_SERVER_URL) : undefined;
87
- const url = server ? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined;
88
- if (forgejo) {
89
- const workflow = forgejoWorkflowFile(env);
90
- const api = env.GITHUB_API_URL ?? (server ? `${server}/api/v1` : undefined);
91
- if (!api || !workflow || !env.GITHUB_REF_NAME) return undefined;
92
- return {
93
- forge: "forgejo",
94
- api: trimSlash(api),
95
- repo: env.GITHUB_REPOSITORY,
96
- run: env.GITHUB_RUN_ID,
97
- workflow,
98
- ref: env.GITHUB_REF_NAME,
99
- ...(url ? { url } : {}),
100
- };
101
- }
102
- const attempt = Number(env.GITHUB_RUN_ATTEMPT);
103
- return {
104
- forge: "github",
105
- api: trimSlash(env.GITHUB_API_URL ?? "https://api.github.com"),
106
- repo: env.GITHUB_REPOSITORY,
107
- run: env.GITHUB_RUN_ID,
108
- ...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}),
109
- ...(url ? { url } : {}),
110
- };
111
- }
112
-
113
- /**
114
- * The workflow file a Forgejo run came from, by its base name. Forgejo names
115
- * a workflow by its file (`GITHUB_WORKFLOW`, `deploy.yml`); `GITHUB_WORKFLOW_REF`
116
- * (`owner/repo/.forgejo/workflows/deploy.yml@refs/heads/main`) says it too.
117
- */
118
- function forgejoWorkflowFile(env: Record<string, string | undefined>): string | undefined {
119
- const fromRef = env.GITHUB_WORKFLOW_REF?.split("@")[0]?.split("/").pop();
120
- if (fromRef && /\.ya?ml$/.test(fromRef)) return fromRef;
121
- const name = env.GITHUB_WORKFLOW;
122
- return name && /\.ya?ml$/.test(name) ? name.split("/").pop() : undefined;
123
- }
124
-
125
- /** Whether `value` is a {@link GateRunLocator} a resume can use. */
126
- export function isGateRunLocator(value: unknown): value is GateRunLocator {
127
- if (!value || typeof value !== "object") return false;
128
- const v = value as Record<string, unknown>;
129
- if (typeof v.api !== "string" || typeof v.repo !== "string" || typeof v.run !== "string") return false;
130
- if (v.forge === "github") return true;
131
- if (v.forge === "gitlab") return typeof v.job === "string";
132
- if (v.forge === "forgejo") return typeof v.workflow === "string" && typeof v.ref === "string";
133
- return false;
134
- }
41
+ // The locator is read where a pending fact is recorded, which `@intentius/chant/point-decision` does too, so it lives on its own (#3835).
42
+ export { isGateRunLocator, resolveGateRunLocator, type GateRunLocator } from "./gate-run-locator";
135
43
 
136
44
  /** What {@link resumeGateRun} did. */
137
45
  export type GateResumeOutcome =
@@ -145,6 +53,8 @@ export interface ResumeGateRunOptions {
145
53
  fetch?: ForgeFetch;
146
54
  }
147
55
 
56
+ const trimSlash = (s: string): string => s.replace(/\/+$/, "");
57
+
148
58
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
149
59
 
150
60
  function client(base: string, headers: Record<string, string>, fetchImpl: ForgeFetch) {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Where a CI job that waits at a gate runs, and what its forge needs to start
3
+ * it again (#3683). Read from the job's environment when a pending fact is
4
+ * recorded; `./gate-resume.ts` uses it to resume the job. It imports nothing
5
+ * at run time, so `@intentius/chant/point-decision` records it on the pending
6
+ * fact of a gate that asks a decision point too (#3835).
7
+ */
8
+
9
+ import type { ForgeKind } from "../pr-forge";
10
+
11
+ /** Where the job that recorded a pending fact runs, and what its forge needs to start it again. */
12
+ export interface GateRunLocator {
13
+ forge: ForgeKind;
14
+ /** The forge's API base: `$GITHUB_API_URL` (GitHub and Forgejo) or `$CI_API_V4_URL` (GitLab). */
15
+ api: string;
16
+ /** `owner/name` on GitHub and Forgejo, the project id on GitLab. */
17
+ repo: string;
18
+ /** The run: GitHub's and Forgejo's run id, GitLab's pipeline id. */
19
+ run: string;
20
+ /** GitHub: the run attempt that waited. A later attempt means it was already re-run. */
21
+ attempt?: number;
22
+ /** GitLab: the job that waited. */
23
+ job?: string;
24
+ /** GitLab: that job's name, to find a retry of it. */
25
+ jobName?: string;
26
+ /** Forgejo: the workflow file the run came from, which a dispatch names. */
27
+ workflow?: string;
28
+ /** Forgejo: the branch a dispatch runs on. */
29
+ ref?: string;
30
+ /** The run's page, for a person. */
31
+ url?: string;
32
+ }
33
+
34
+ const trimSlash = (s: string): string => s.replace(/\/+$/, "");
35
+
36
+ /**
37
+ * The locator of the CI job this process runs in, or undefined outside CI or
38
+ * when the environment lacks a part a resume needs. Forgejo Actions sets the
39
+ * `GITHUB_*` variables too, and is told apart by `FORGEJO_ACTIONS` or
40
+ * `GITEA_ACTIONS`.
41
+ */
42
+ export function resolveGateRunLocator(env: Record<string, string | undefined> = process.env): GateRunLocator | undefined {
43
+ if (env.GITLAB_CI === "true") {
44
+ if (!env.CI_API_V4_URL || !env.CI_PROJECT_ID || !env.CI_PIPELINE_ID || !env.CI_JOB_ID) return undefined;
45
+ return {
46
+ forge: "gitlab",
47
+ api: trimSlash(env.CI_API_V4_URL),
48
+ repo: env.CI_PROJECT_ID,
49
+ run: env.CI_PIPELINE_ID,
50
+ job: env.CI_JOB_ID,
51
+ ...(env.CI_JOB_NAME ? { jobName: env.CI_JOB_NAME } : {}),
52
+ ...(env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}),
53
+ };
54
+ }
55
+ if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_REPOSITORY || !env.GITHUB_RUN_ID) return undefined;
56
+ const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true";
57
+ const server = env.GITHUB_SERVER_URL ? trimSlash(env.GITHUB_SERVER_URL) : undefined;
58
+ const url = server ? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined;
59
+ if (forgejo) {
60
+ const workflow = forgejoWorkflowFile(env);
61
+ const api = env.GITHUB_API_URL ?? (server ? `${server}/api/v1` : undefined);
62
+ if (!api || !workflow || !env.GITHUB_REF_NAME) return undefined;
63
+ return {
64
+ forge: "forgejo",
65
+ api: trimSlash(api),
66
+ repo: env.GITHUB_REPOSITORY,
67
+ run: env.GITHUB_RUN_ID,
68
+ workflow,
69
+ ref: env.GITHUB_REF_NAME,
70
+ ...(url ? { url } : {}),
71
+ };
72
+ }
73
+ const attempt = Number(env.GITHUB_RUN_ATTEMPT);
74
+ return {
75
+ forge: "github",
76
+ api: trimSlash(env.GITHUB_API_URL ?? "https://api.github.com"),
77
+ repo: env.GITHUB_REPOSITORY,
78
+ run: env.GITHUB_RUN_ID,
79
+ ...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}),
80
+ ...(url ? { url } : {}),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * The workflow file a Forgejo run came from, by its base name. Forgejo names
86
+ * a workflow by its file (`GITHUB_WORKFLOW`, `deploy.yml`); `GITHUB_WORKFLOW_REF`
87
+ * (`owner/repo/.forgejo/workflows/deploy.yml@refs/heads/main`) says it too.
88
+ */
89
+ function forgejoWorkflowFile(env: Record<string, string | undefined>): string | undefined {
90
+ const fromRef = env.GITHUB_WORKFLOW_REF?.split("@")[0]?.split("/").pop();
91
+ if (fromRef && /\.ya?ml$/.test(fromRef)) return fromRef;
92
+ const name = env.GITHUB_WORKFLOW;
93
+ return name && /\.ya?ml$/.test(name) ? name.split("/").pop() : undefined;
94
+ }
95
+
96
+ /** Whether `value` is a {@link GateRunLocator} a resume can use. */
97
+ export function isGateRunLocator(value: unknown): value is GateRunLocator {
98
+ if (!value || typeof value !== "object") return false;
99
+ const v = value as Record<string, unknown>;
100
+ if (typeof v.api !== "string" || typeof v.repo !== "string" || typeof v.run !== "string") return false;
101
+ if (v.forge === "github") return true;
102
+ if (v.forge === "gitlab") return typeof v.job === "string";
103
+ if (v.forge === "forgejo") return typeof v.workflow === "string" && typeof v.ref === "string";
104
+ return false;
105
+ }
package/src/op/index.ts CHANGED
@@ -68,6 +68,8 @@ export {
68
68
  } from "./op-waves";
69
69
  export type { OpWave, OpWaveApplyHooksRunner, OpWaveGatePolicy, OpWaveHook, OpWaveHookFailure, OpWaveHookMoment, OpWaveHooks, OpWaveJob, OpWaveRun, OpWavesDigest, OpWavesOverride, OpWavesSpec } from "./op-waves";
70
70
  export {
71
+ OP_WAVE_APPLY_OUTCOMES,
72
+ OP_WAVE_MAX_REPLANS,
71
73
  opWaveHooksAtBase,
72
74
  opWaveNeedsApproval,
73
75
  opWavePlansPath,
@@ -84,6 +86,7 @@ export type {
84
86
  OpWaveBaseRules,
85
87
  OpWaveDecision,
86
88
  OpWaveApplyHooksRun,
89
+ OpWaveApplyOutcome,
87
90
  OpWaveExec,
88
91
  OpWaveHeldOn,
89
92
  OpWaveHoldRequest,
@@ -6,7 +6,9 @@
6
6
  * review. A share job that refused a run whose plan moved (exit 4) sends
7
7
  * `refused`. A wave whose plan, apply or stage hook failed (exit 1) sends
8
8
  * `failed`. A wave the product's policy refused (exit 7, #3821) sends
9
- * `refused` with the runs it denied. A share job of a waiting wave sends nothing, since its deciding
9
+ * `refused` with the runs it denied, and one whose apply was held by another
10
+ * run (exit 5) sends `refused` with status `held`; one that stood down for a
11
+ * newer run (exit 6) sends nothing. A share job of a waiting wave sends nothing, since its deciding
10
12
  * job already did, unless a hook in the share job itself held it (#3762). Delivery is `../notify.ts`: it never throws, and the job's exit code
11
13
  * is what the wave decided.
12
14
  */
@@ -70,6 +72,19 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
70
72
  : "the policy denied these runs, and no override counts for them",
71
73
  };
72
74
  }
75
+ // #3821: an apply that stood down for a newer run is no news; one held by another run is refused.
76
+ if (result.exitCode === 6 && result.outcome?.outcome === "stood-down") return undefined;
77
+ if (result.exitCode === 5 && result.outcome?.outcome === "held") {
78
+ const holder = result.outcome.holder;
79
+ return {
80
+ ...base,
81
+ event: "refused",
82
+ status: "held",
83
+ summary: `${label} applied nothing: another run${holder ? ` (${holder.run})` : ""} holds what it changes`,
84
+ targets: decision?.members.map((m) => m.member) ?? [],
85
+ reason: result.outcome.reason ?? `another run${holder ? `, ${holder.run},` : ""} holds what the apply changes; apply again once it is done`,
86
+ };
87
+ }
73
88
  if (result.exitCode === 4) {
74
89
  return {
75
90
  ...base,