@intentius/chant 0.53.1 → 0.54.1

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 (59) hide show
  1. package/dist/audit/catalog.d.ts +4 -0
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +9 -0
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/nginx.d.ts +53 -0
  6. package/dist/audit/nginx.d.ts.map +1 -0
  7. package/dist/audit/rules-doc.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts.map +1 -1
  9. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  10. package/dist/cli/commands/carve-status.d.ts +56 -0
  11. package/dist/cli/commands/carve-status.d.ts.map +1 -0
  12. package/dist/cli/handlers/carve-status.d.ts +9 -0
  13. package/dist/cli/handlers/carve-status.d.ts.map +1 -0
  14. package/dist/cli/handlers/components.d.ts.map +1 -1
  15. package/dist/cli/handlers/run.d.ts.map +1 -1
  16. package/dist/cli/main.d.ts.map +1 -1
  17. package/dist/components/auto-release.d.ts +3 -1
  18. package/dist/components/auto-release.d.ts.map +1 -1
  19. package/dist/components/cli-support.d.ts +2 -0
  20. package/dist/components/cli-support.d.ts.map +1 -1
  21. package/dist/graph-ir.d.ts +7 -0
  22. package/dist/graph-ir.d.ts.map +1 -1
  23. package/dist/lexicon.d.ts +8 -0
  24. package/dist/lexicon.d.ts.map +1 -1
  25. package/dist/lifecycle/release-ledger.d.ts +55 -1
  26. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  27. package/dist/op/activity-contract.d.ts +15 -1
  28. package/dist/op/activity-contract.d.ts.map +1 -1
  29. package/dist/terraform/manifest.d.ts +31 -6
  30. package/dist/terraform/manifest.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/audit/catalog.ts +35 -0
  33. package/src/audit/discover.ts +11 -0
  34. package/src/audit/edge-init-safety.test.ts +36 -3
  35. package/src/audit/nginx.test.ts +180 -0
  36. package/src/audit/nginx.ts +379 -0
  37. package/src/audit/rules-doc.ts +1 -0
  38. package/src/cli/commands/audit.test.ts +35 -0
  39. package/src/cli/commands/audit.ts +8 -3
  40. package/src/cli/commands/carve-apply.test.ts +4 -1
  41. package/src/cli/commands/carve-apply.ts +4 -2
  42. package/src/cli/commands/carve-bridge.test.ts +2 -1
  43. package/src/cli/commands/carve-emit-state.test.ts +3 -1
  44. package/src/cli/commands/carve-status.test.ts +116 -0
  45. package/src/cli/commands/carve-status.ts +163 -0
  46. package/src/cli/handlers/carve-status.ts +30 -0
  47. package/src/cli/handlers/components.ts +5 -1
  48. package/src/cli/handlers/graph.ts +3 -0
  49. package/src/cli/handlers/run.ts +4 -1
  50. package/src/cli/main.ts +8 -0
  51. package/src/components/auto-release.ts +4 -1
  52. package/src/components/cli-support.ts +4 -2
  53. package/src/graph-ir.ts +7 -0
  54. package/src/lexicon.ts +8 -0
  55. package/src/lifecycle/release-ledger.test.ts +64 -0
  56. package/src/lifecycle/release-ledger.ts +79 -1
  57. package/src/op/activity-contract.ts +14 -0
  58. package/src/terraform/manifest.test.ts +52 -0
  59. package/src/terraform/manifest.ts +72 -10
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `chant carve status` — the read over a tree of carve manifests (#2038).
3
+ *
4
+ * emit → bridge → apply each persist their step into `<target>.carve.json`
5
+ * (./../../terraform/manifest.ts), but until now nothing read that state back
6
+ * over more than one output dir at a time: a renderer wanting "what carves
7
+ * exist under this project, and how far along is each" had to walk the tree
8
+ * itself and guess at depth. This makes the walk a contract: recurse from
9
+ * `--from` (default: cwd), read every manifest the way bridge/apply's own
10
+ * `resolveCarveManifest` reads one, and answer with each manifest's target,
11
+ * stage, and path. Read-only — it writes nothing and touches no live resource.
12
+ */
13
+
14
+ import { existsSync, readdirSync, statSync } from "fs";
15
+ import { join, relative, resolve } from "path";
16
+ import {
17
+ CARVE_MANIFEST_SUFFIX,
18
+ readCarveManifest,
19
+ type CarveManifest,
20
+ } from "../../terraform/manifest";
21
+
22
+ /**
23
+ * How far along a carve is — the highest recorded step. `planned` is a
24
+ * manifest holding only the boundary classification (written, but `carve
25
+ * emit` recorded no emit section — e.g. a hand-seeded or partially-run
26
+ * carve).
27
+ */
28
+ export type CarveStage = "planned" | "emitted" | "bridged" | "applied";
29
+
30
+ export interface CarveStatusRow {
31
+ /** Manifest path, relative to the walk root. */
32
+ path: string;
33
+ /** Terraform address of the carved resource. */
34
+ target: string;
35
+ /** Terraform resource type, when the manifest recorded one. */
36
+ tfType?: string;
37
+ /** The highest recorded step. */
38
+ stage: CarveStage;
39
+ /** ISO-8601 timestamp of each recorded step. */
40
+ at: { emit?: string; bridge?: string; apply?: string };
41
+ /** Emitted chant source file path(s), exactly as the manifest records them. */
42
+ emittedFiles?: string[];
43
+ }
44
+
45
+ export interface CarveStatusOptions {
46
+ /** Root of the walk (`--from`). Default: the current directory. */
47
+ from?: string;
48
+ }
49
+
50
+ export interface CarveStatusResult {
51
+ ok: boolean;
52
+ error?: string;
53
+ /** The resolved walk root. */
54
+ from?: string;
55
+ /** One row per readable manifest, sorted by path. */
56
+ carves?: CarveStatusRow[];
57
+ /** Manifest-suffixed files that did not read as a manifest (wrong version, malformed). */
58
+ unreadable?: string[];
59
+ }
60
+
61
+ /**
62
+ * Directories that never hold a carve output dir and make the walk expensive
63
+ * or wrong: dependency trees, VCS internals, build output.
64
+ */
65
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".chant"]);
66
+
67
+ /** Every `*.carve.json` under `root`, found by a bounded recursive walk. */
68
+ function findManifests(root: string): string[] {
69
+ const found: string[] = [];
70
+ const walk = (dir: string): void => {
71
+ let entries: string[];
72
+ try {
73
+ entries = readdirSync(dir);
74
+ } catch {
75
+ return; // unreadable dir — skip rather than fail the whole status
76
+ }
77
+ for (const entry of entries.sort()) {
78
+ if (SKIP_DIRS.has(entry)) continue;
79
+ const path = join(dir, entry);
80
+ let stat;
81
+ try {
82
+ stat = statSync(path);
83
+ } catch {
84
+ continue; // dangling symlink
85
+ }
86
+ if (stat.isDirectory()) walk(path);
87
+ else if (entry.endsWith(CARVE_MANIFEST_SUFFIX)) found.push(path);
88
+ }
89
+ };
90
+ walk(root);
91
+ return found;
92
+ }
93
+
94
+ function stageOf(manifest: CarveManifest): CarveStage {
95
+ if (manifest.apply) return "applied";
96
+ if (manifest.bridge) return "bridged";
97
+ if (manifest.emit) return "emitted";
98
+ return "planned";
99
+ }
100
+
101
+ export function carveStatus(opts: CarveStatusOptions = {}): CarveStatusResult {
102
+ const root = resolve(opts.from ?? process.cwd());
103
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
104
+ return { ok: false, error: `Not a directory: ${root}` };
105
+ }
106
+
107
+ const carves: CarveStatusRow[] = [];
108
+ const unreadable: string[] = [];
109
+ for (const path of findManifests(root)) {
110
+ const rel = relative(root, path);
111
+ const manifest = readCarveManifest(path);
112
+ if (!manifest) {
113
+ unreadable.push(rel);
114
+ continue;
115
+ }
116
+ carves.push({
117
+ path: rel,
118
+ target: manifest.target,
119
+ ...(manifest.tfType ? { tfType: manifest.tfType } : {}),
120
+ stage: stageOf(manifest),
121
+ at: {
122
+ ...(manifest.emit?.at ? { emit: manifest.emit.at } : {}),
123
+ ...(manifest.bridge?.at ? { bridge: manifest.bridge.at } : {}),
124
+ ...(manifest.apply?.at ? { apply: manifest.apply.at } : {}),
125
+ },
126
+ ...(manifest.emit ? { emittedFiles: manifest.emit.files } : {}),
127
+ });
128
+ }
129
+
130
+ return { ok: true, from: root, carves, ...(unreadable.length > 0 ? { unreadable } : {}) };
131
+ }
132
+
133
+ /** The JSON payload `--json` emits — everything but `ok`. */
134
+ export function carveStatusJson(result: CarveStatusResult): Record<string, unknown> {
135
+ return {
136
+ from: result.from,
137
+ carves: result.carves ?? [],
138
+ ...(result.unreadable ? { unreadable: result.unreadable } : {}),
139
+ };
140
+ }
141
+
142
+ export function formatCarveStatus(result: CarveStatusResult): string {
143
+ const carves = result.carves ?? [];
144
+ if (carves.length === 0) {
145
+ return `No carve manifests under ${result.from} — run \`chant carve emit\` first.`;
146
+ }
147
+ const L: string[] = [];
148
+ L.push(`${carves.length} carve${carves.length === 1 ? "" : "s"} under ${result.from}`);
149
+ L.push("");
150
+ for (const row of carves) {
151
+ const when = row.at.apply ?? row.at.bridge ?? row.at.emit;
152
+ L.push(` ${row.target} [${row.stage}${when ? ` @ ${when}` : ""}]`);
153
+ L.push(` manifest: ${row.path}`);
154
+ if (row.emittedFiles && row.emittedFiles.length > 0) {
155
+ L.push(` emitted: ${row.emittedFiles.join(", ")}`);
156
+ }
157
+ }
158
+ if (result.unreadable && result.unreadable.length > 0) {
159
+ L.push("");
160
+ L.push(` Unreadable (malformed or unknown version): ${result.unreadable.join(", ")}`);
161
+ }
162
+ return L.join("\n");
163
+ }
@@ -0,0 +1,30 @@
1
+ import { carveStatus, carveStatusJson, formatCarveStatus } from "../commands/carve-status";
2
+ import { formatError } from "../format";
3
+ import type { CommandContext } from "../registry";
4
+
5
+ /**
6
+ * `chant carve status [--from <dir>] [--json]` — the read-only status read
7
+ * over a tree of carve manifests (#2038). Walks from `--from` (default: cwd),
8
+ * reports every manifest's target, stage (planned/emitted/bridged/applied)
9
+ * and path. No plugins, no project, no cloud call.
10
+ */
11
+ export async function runCarveStatus(ctx: CommandContext): Promise<number> {
12
+ const { args } = ctx;
13
+
14
+ const result = carveStatus({ from: args.migrateFrom /* `--from <dir>` */ });
15
+
16
+ if (!result.ok) {
17
+ console.error(formatError({
18
+ message: result.error ?? "carve status failed",
19
+ hint: "chant carve status --from ./tf --json",
20
+ }));
21
+ return 1;
22
+ }
23
+
24
+ if (args.json) {
25
+ console.log(JSON.stringify(carveStatusJson(result), null, 2));
26
+ } else {
27
+ console.log(formatCarveStatus(result));
28
+ }
29
+ return 0;
30
+ }
@@ -34,6 +34,7 @@ import {
34
34
  readReleaseLedger,
35
35
  listReleaseEnvironments,
36
36
  latestPerComponent,
37
+ resolveRunId,
37
38
  InvalidReleaseRecordError,
38
39
  } from "../../lifecycle/release-ledger";
39
40
  import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments, mergeLiveEvidence, type LiveComponentEvidence } from "../../lifecycle/status";
@@ -94,7 +95,9 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
94
95
  }
95
96
 
96
97
  const gitSha = args.gitSha ?? (await getHeadCommit().catch(() => undefined));
97
- const runId = args.runId ?? process.env.GITHUB_RUN_ID ?? process.env.CI_PIPELINE_ID ?? `local-${Date.now()}`;
98
+ // Id + origin resolved together from the same environment (#2045), so the
99
+ // recorded runId always says which id space it lives in and how to reach it.
100
+ const { runId, runOrigin } = resolveRunId(args.runId);
98
101
  const actor = args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER;
99
102
  // Who approved a gated change (#1035). Optional — omitted for an ungated
100
103
  // change, and never defaulted to `actor`, since recording the approver as the
@@ -122,6 +125,7 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
122
125
  digest,
123
126
  gitSha,
124
127
  runId,
128
+ ...(runOrigin ? { runOrigin } : {}),
125
129
  timestamp,
126
130
  actor,
127
131
  ...(approver ? { approver } : {}),
@@ -635,6 +635,9 @@ async function buildPipelineProjection(
635
635
  success: true,
636
636
  pipeline: {
637
637
  provider: lexicon,
638
+ // The environment the projected pipeline deploys (#2046) — the
639
+ // generator's own resolution, carried rather than re-derived.
640
+ ...(result.env ? { env: result.env } : {}),
638
641
  stages: result.stages ?? [],
639
642
  nodes: jobs.map((j) => ({ id: j.jobName, kind: "CIJob" as const, component: j.component, stage: j.stage })),
640
643
  edges: jobs.flatMap((j) => j.needs.map((dep) => ({ from: j.jobName, to: dep, kind: "needs" as const }))),
@@ -655,6 +655,8 @@ async function recordAutoReleasesForRun(
655
655
  success: true,
656
656
  records: componentResult.records,
657
657
  runId,
658
+ // A local-executor id resolves nowhere — say so in a field (#2045).
659
+ runOrigin: { forge: "local" },
658
660
  },
659
661
  { disabled },
660
662
  );
@@ -1030,7 +1032,8 @@ async function runComponentTemporal(
1030
1032
  const disabled = resolveAutoReleaseDisabled(config, ctx.args.noReleaseRecord);
1031
1033
  const env = ctx.args.env ?? "local";
1032
1034
  const outcome = await maybeRecordAutoRelease(
1033
- { component: component.name, env, success: true, digest, runId: finalDesc.runId },
1035
+ // A Temporal workflow run id lives in the orchestrator's id space (#2045).
1036
+ { component: component.name, env, success: true, digest, runId: finalDesc.runId, runOrigin: { forge: "op" } },
1034
1037
  { disabled },
1035
1038
  );
1036
1039
  if (!outcome.recorded && outcome.reason === "error") {
package/src/cli/main.ts CHANGED
@@ -23,6 +23,7 @@ import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
23
23
  import { runCarveEmit } from "./handlers/carve-emit";
24
24
  import { runCarveBridge } from "./handlers/carve-bridge";
25
25
  import { runCarveApply } from "./handlers/carve-apply";
26
+ import { runCarveStatus } from "./handlers/carve-status";
26
27
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
27
28
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
28
29
  import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
@@ -498,6 +499,10 @@ Commands:
498
499
  --env <env> is optional with a carve manifest present.
499
500
  --write-source stamps the ownership marker
500
501
  into the emitted chant source.
502
+ carve status Status read over a tree of carve manifests: every
503
+ [--from <dir>] *.carve.json under --from (default: cwd) with
504
+ (--json) its target, stage (planned/emitted/bridged/
505
+ applied) and path. Read-only.
501
506
 
502
507
  Ops:
503
508
  run <name> Start an Op workflow (spawns worker + submits to Temporal)
@@ -872,6 +877,9 @@ const registry: CommandDef[] = [
872
877
  // Apply graduation (#197): ownership marker + finalized apply runbook.
873
878
  // BYOL-honest — no cloud call; --write saves the graduation doc.
874
879
  { name: "carve apply", handler: runCarveApply },
880
+ // Status read over a tree of carve manifests (#2038): the contract a
881
+ // renderer replaces its own walk-and-guess discovery with. Read-only.
882
+ { name: "carve status", handler: runCarveStatus },
875
883
  { name: "init", handler: runInit },
876
884
  { name: "init lexicon", handler: runInitLexicon },
877
885
  { name: "update", handler: runUpdate },
@@ -30,7 +30,7 @@
30
30
  * env vars, timestamp is a real `new Date()` taken at record time).
31
31
  */
32
32
  import { getHeadCommit, pushLifecycle, StaleLifecycleBranchError } from "../lifecycle/git";
33
- import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseRecord } from "../lifecycle/release-ledger";
33
+ import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseRecord, type RunOrigin } from "../lifecycle/release-ledger";
34
34
  import type { DriverStepRecord } from "./driver";
35
35
 
36
36
  /**
@@ -117,6 +117,8 @@ export interface AutoReleaseRunInfo {
117
117
  digest?: string;
118
118
  /** Orchestrator run identifier — a Temporal `runId`, or a locally generated id for the local executor (mirrors `runComponentsReleaseRecord`'s `--run-id` default). */
119
119
  runId: string;
120
+ /** The id space `runId` lives in (#2045) — `{ forge: "op" }` for a Temporal run, `{ forge: "local" }` for a local-executor mint. Recorded verbatim as the release record's `runOrigin`. */
121
+ runOrigin?: RunOrigin;
120
122
  /** The bypassed capability-profile divergences, when the caller deliberately overrode a deploy-time profile assertion (chant #1244) — recorded verbatim as the release record's `profileOverride`. */
121
123
  profileOverride?: string;
122
124
  /** The deploy's input-side digest, when `digest` is a rendered-content identity (a pinned helm deploy, chant #1242) — recorded as the release record's `inputDigest` so ledger queries can still join on inputs across clusters whose bytes legitimately differ. */
@@ -204,6 +206,7 @@ export async function maybeRecordAutoRelease(
204
206
  digest,
205
207
  gitSha,
206
208
  runId: run.runId,
209
+ ...(run.runOrigin ? { runOrigin: run.runOrigin } : {}),
207
210
  timestamp,
208
211
  actor,
209
212
  ...(run.profileOverride ? { profileOverride: run.profileOverride } : {}),
@@ -218,6 +218,8 @@ export interface GenerateComponentsResult {
218
218
  stages?: string[];
219
219
  /** Every generated job, for a machine-readable view (`--format json`). */
220
220
  jobs?: Array<{ jobName: string; component: string; stage: string; needs: string[] }>;
221
+ /** The environment the pipeline deploys — the generator's own resolution (`ComponentPipelineResult.env`, #2046). */
222
+ env?: string;
221
223
  error?: string;
222
224
  /** This invocation's resolved build-time parameters (chant #1108) — the generate-mode counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Empty when the project declares/supplies none. */
223
225
  buildParams?: BuildParamProvenance[];
@@ -287,8 +289,8 @@ export async function generateComponentsPipeline(
287
289
  }));
288
290
 
289
291
  try {
290
- const { yaml, stages, jobs } = plugin.generateComponentPipeline(driverComponents, options);
291
- return { success: true, yaml, stages, jobs, buildParams };
292
+ const { yaml, stages, jobs, env } = plugin.generateComponentPipeline(driverComponents, options);
293
+ return { success: true, yaml, stages, jobs, ...(env ? { env } : {}), buildParams };
292
294
  } catch (err) {
293
295
  if (err instanceof UnknownDependencyError || err instanceof DependencyCycleError) {
294
296
  return { success: false, error: err.message };
package/src/graph-ir.ts CHANGED
@@ -190,6 +190,13 @@ export interface IRPipelineEdge {
190
190
  export interface IRPipeline {
191
191
  /** The CI-provider lexicon that produced this projection (e.g. "gitlab"). */
192
192
  provider: string;
193
+ /**
194
+ * The environment the projected pipeline deploys (#2046) — copied from the
195
+ * generator's own resolution (`ComponentPipelineResult.env`), so the IR
196
+ * carries the identity instead of leaving a consumer to lex it out of a
197
+ * job's run line.
198
+ */
199
+ env?: string;
193
200
  /** Wave-ordered stage names — 1:1 with the component graph's `groups.byWave` keys. */
194
201
  stages: string[];
195
202
  nodes: IRPipelineNode[];
package/src/lexicon.ts CHANGED
@@ -461,6 +461,14 @@ export interface ComponentPipelineResult {
461
461
  stages: string[];
462
462
  /** Every generated job, in emit order. */
463
463
  jobs: ComponentPipelineJob[];
464
+ /**
465
+ * The environment this pipeline deploys — `options.env` with the
466
+ * generator's default applied (#2046). Generators set it so a consumer
467
+ * (the graph IR's `pipeline` projection, behold) can join a pipeline to
468
+ * its environment without lexing a job's `run:`/`script:` line, where the
469
+ * value otherwise survives only as a shell argument.
470
+ */
471
+ env?: string;
464
472
  }
465
473
 
466
474
  /**
@@ -11,6 +11,7 @@ import {
11
11
  InvalidReleaseRecordError,
12
12
  latestPerComponent,
13
13
  recordsForDigest,
14
+ resolveRunId,
14
15
  type ReleaseRecord,
15
16
  type ReleaseRecordInput,
16
17
  } from "./release-ledger";
@@ -234,4 +235,67 @@ describe("release-ledger", () => {
234
235
  expect(found.every((r) => r.digest === "sha256:shared")).toBe(true);
235
236
  });
236
237
  });
238
+
239
+ describe("resolveRunId (#2045)", () => {
240
+ test("GitHub Actions env resolves id + origin together, with repo and run url", () => {
241
+ const env = {
242
+ GITHUB_RUN_ID: "18234771902",
243
+ GITHUB_REPOSITORY: "INTENTIUS/chant",
244
+ GITHUB_SERVER_URL: "https://github.com",
245
+ };
246
+ expect(resolveRunId(undefined, env)).toEqual({
247
+ runId: "18234771902",
248
+ runOrigin: {
249
+ forge: "github",
250
+ repo: "INTENTIUS/chant",
251
+ url: "https://github.com/INTENTIUS/chant/actions/runs/18234771902",
252
+ },
253
+ });
254
+ });
255
+
256
+ test("a Forgejo instance resolves through the same env contract to its own host", () => {
257
+ const env = {
258
+ GITHUB_RUN_ID: "42",
259
+ GITHUB_REPOSITORY: "intentius/loomster",
260
+ GITHUB_SERVER_URL: "https://forge.example.dev",
261
+ };
262
+ expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/intentius/loomster/actions/runs/42");
263
+ });
264
+
265
+ test("GitLab CI env records the project path and takes CI_PIPELINE_URL verbatim", () => {
266
+ const env = {
267
+ CI_PIPELINE_ID: "991",
268
+ CI_PROJECT_PATH: "group/app",
269
+ CI_PIPELINE_URL: "https://gitlab.com/group/app/-/pipelines/991",
270
+ };
271
+ expect(resolveRunId(undefined, env)).toEqual({
272
+ runId: "991",
273
+ runOrigin: { forge: "gitlab", repo: "group/app", url: "https://gitlab.com/group/app/-/pipelines/991" },
274
+ });
275
+ });
276
+
277
+ test("outside CI the mint says local in a field, not only in the id's spelling", () => {
278
+ const { runId, runOrigin } = resolveRunId(undefined, {});
279
+ expect(runId).toMatch(/^local-\d+$/);
280
+ expect(runOrigin).toEqual({ forge: "local" });
281
+ });
282
+
283
+ test("an explicit id gets an origin only when it matches what the environment advertises", () => {
284
+ const env = { GITHUB_RUN_ID: "42", GITHUB_REPOSITORY: "o/r", GITHUB_SERVER_URL: "https://github.com" };
285
+ expect(resolveRunId("42", env).runOrigin?.forge).toBe("github");
286
+ expect(resolveRunId("something-else", env).runOrigin).toBeUndefined();
287
+ });
288
+
289
+ test("a record carrying runOrigin round-trips through the ledger", async () => {
290
+ await withTestDir(async (dir) => {
291
+ await initRepo(dir);
292
+ const input = makeInput({
293
+ runOrigin: { forge: "github", repo: "o/r", url: "https://github.com/o/r/actions/runs/1" },
294
+ });
295
+ await appendReleaseRecord(input, { cwd: dir });
296
+ const { records } = await readReleaseLedger("prod", { cwd: dir });
297
+ expect(records[0].runOrigin).toEqual(input.runOrigin);
298
+ });
299
+ });
300
+ });
237
301
  });
@@ -31,6 +31,25 @@
31
31
  import { sortedJsonReplacer } from "../utils";
32
32
  import { appendReleaseRecordLine, readReleaseLedgerLines, listLedgerEnvironments as gitListLedgerEnvironments } from "./git";
33
33
 
34
+ /**
35
+ * The id space a `ReleaseRecord.runId` lives in, and how to reach the run
36
+ * (#2045).
37
+ */
38
+ export interface RunOrigin {
39
+ /**
40
+ * Which id space minted `runId`: `github` (a GitHub Actions run id — also
41
+ * the spelling Forgejo Actions uses, since it implements the same env
42
+ * contract; `url` disambiguates the actual host), `gitlab` (a CI pipeline
43
+ * id), `op` (an orchestrator/Op run id), or `local` (minted on the machine
44
+ * that ran the deploy, resolvable nowhere).
45
+ */
46
+ forge: "github" | "gitlab" | "op" | "local";
47
+ /** Repo/project slug on the forge (`GITHUB_REPOSITORY` / `CI_PROJECT_PATH`), when the environment named one. */
48
+ repo?: string;
49
+ /** Resolved link to the run, when the environment provided enough to build one. */
50
+ url?: string;
51
+ }
52
+
34
53
  /**
35
54
  * One immutable deploy record: `(component, env, artifact digest, git sha,
36
55
  * run id, timestamp, actor)`, referencing the build archive by digest — the
@@ -54,8 +73,19 @@ export interface ReleaseRecord {
54
73
  digest: string;
55
74
  /** Git commit SHA the deploy was built from. */
56
75
  gitSha: string;
57
- /** Orchestrator run identifier (a `DriverRunResult`/Op run id, or a CI run id in generate mode) — the pointer back into the deploy event log. */
76
+ /** Orchestrator run identifier (a `DriverRunResult`/Op run id, or a CI run id in generate mode) — the pointer back into the deploy event log. Which id space it lives in, and how to follow it, is `runOrigin`'s job (#2045). */
58
77
  runId: string;
78
+ /**
79
+ * Where `runId` can be resolved (#2045). A bare `runId` names a run in one
80
+ * of several mutually incompatible id spaces — a GitHub Actions run id, a
81
+ * GitLab pipeline id, an Op run id, a locally minted id — and without this
82
+ * a reader can tell them apart only by inference from whoever wrote the
83
+ * record. Written at record time from the same environment the id came
84
+ * from, so the two cannot skew. Optional: records written before this
85
+ * field existed, and callers passing an id whose origin they did not
86
+ * declare, simply have none.
87
+ */
88
+ runOrigin?: RunOrigin;
59
89
  /**
60
90
  * When this record was written. The caller supplies this — the session
61
91
  * cannot call `Date.now()` reliably in every context, so the CLI resolves
@@ -132,6 +162,54 @@ export function validateReleaseRecord(record: Partial<ReleaseRecord>): string[]
132
162
  /** Input a caller supplies to record one deploy — `version` is filled in, everything else is required (see `ReleaseRecord`). */
133
163
  export type ReleaseRecordInput = Omit<ReleaseRecord, "version">;
134
164
 
165
+ /**
166
+ * Resolve a run id and its origin from the same environment, in one step, so
167
+ * the two cannot skew (#2045). Resolution order mirrors what
168
+ * `chant components release` always did — explicit id, then
169
+ * `GITHUB_RUN_ID`, then `CI_PIPELINE_ID`, then a locally minted
170
+ * `local-<ms>` — but now each source also names its id space:
171
+ *
172
+ * - the GitHub path records `repo` (`GITHUB_REPOSITORY`) and builds the run
173
+ * `url` from `GITHUB_SERVER_URL` — which also makes a Forgejo instance's
174
+ * runs resolvable, since Forgejo Actions implements the same env contract;
175
+ * - the GitLab path records `repo` (`CI_PROJECT_PATH`) and takes
176
+ * `CI_PIPELINE_URL` verbatim;
177
+ * - the local mint says `local` in a field instead of only in the id's
178
+ * spelling.
179
+ *
180
+ * An explicit id still gets an origin when it matches the id the environment
181
+ * itself is advertising (a CI job passing `--run-id $GITHUB_RUN_ID`);
182
+ * otherwise the caller's id is recorded without one — guessing an origin the
183
+ * caller did not state would be exactly the inference this field exists to
184
+ * remove.
185
+ */
186
+ export function resolveRunId(
187
+ explicit?: string,
188
+ env: NodeJS.ProcessEnv = process.env,
189
+ ): { runId: string; runOrigin?: RunOrigin } {
190
+ const githubOrigin = (id: string): RunOrigin => ({
191
+ forge: "github",
192
+ ...(env.GITHUB_REPOSITORY ? { repo: env.GITHUB_REPOSITORY } : {}),
193
+ ...(env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY
194
+ ? { url: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${id}` }
195
+ : {}),
196
+ });
197
+ const gitlabOrigin = (): RunOrigin => ({
198
+ forge: "gitlab",
199
+ ...(env.CI_PROJECT_PATH ? { repo: env.CI_PROJECT_PATH } : {}),
200
+ ...(env.CI_PIPELINE_URL ? { url: env.CI_PIPELINE_URL } : {}),
201
+ });
202
+
203
+ if (explicit) {
204
+ if (env.GITHUB_RUN_ID && explicit === env.GITHUB_RUN_ID) return { runId: explicit, runOrigin: githubOrigin(explicit) };
205
+ if (env.CI_PIPELINE_ID && explicit === env.CI_PIPELINE_ID) return { runId: explicit, runOrigin: gitlabOrigin() };
206
+ return { runId: explicit };
207
+ }
208
+ if (env.GITHUB_RUN_ID) return { runId: env.GITHUB_RUN_ID, runOrigin: githubOrigin(env.GITHUB_RUN_ID) };
209
+ if (env.CI_PIPELINE_ID) return { runId: env.CI_PIPELINE_ID, runOrigin: gitlabOrigin() };
210
+ return { runId: `local-${Date.now()}`, runOrigin: { forge: "local" } };
211
+ }
212
+
135
213
  /**
136
214
  * Append one immutable release record to the `<env>` ledger on the
137
215
  * `chant/lifecycle` orphan branch. Throws `InvalidReleaseRecordError` before
@@ -75,6 +75,18 @@ export interface ActivityContract<Args = unknown, Return = unknown> {
75
75
  * per #1290 — a later step's reference into this one's output.
76
76
  */
77
77
  returns?: z.ZodType<Return>;
78
+ /**
79
+ * Names of top-level args whose values identify what the activity's effect
80
+ * touches in the estate (#2022) — an entity id, a resource address, a
81
+ * target endpoint — as opposed to scope (`env`, a stack name) or mechanics
82
+ * (a timeout, a mode). op.json's IR resolves these per step into
83
+ * `OpIRActivityStep.entities`, so a renderer can join a step to the estate
84
+ * nodes it touches instead of inventing joins off scope fields. Optional
85
+ * and partial by design, like the contract itself: an activity whose
86
+ * touched set is not spelled in its args (e.g. an applier that derives
87
+ * stack members internally) simply declares none.
88
+ */
89
+ entities?: string[];
78
90
  }
79
91
 
80
92
  /** Declare an activity contract. */
@@ -82,12 +94,14 @@ export function activityContract<ArgsSchema extends z.ZodTypeAny, ReturnSchema e
82
94
  name: string,
83
95
  args: ArgsSchema,
84
96
  returns?: ReturnSchema,
97
+ opts?: { entities?: string[] },
85
98
  ): ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown> {
86
99
  return {
87
100
  [CONTRACT_BRAND]: true,
88
101
  name,
89
102
  args,
90
103
  ...(returns ? { returns } : {}),
104
+ ...(opts?.entities && opts.entities.length > 0 ? { entities: opts.entities } : {}),
91
105
  } as ActivityContract<z.infer<ArgsSchema>, ReturnSchema extends z.ZodTypeAny ? z.infer<ReturnSchema> : unknown>;
92
106
  }
93
107
 
@@ -3,10 +3,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import {
6
+ CARVE_MANIFEST_VERSION,
6
7
  carveManifestPath,
7
8
  listCarveManifests,
8
9
  readCarveManifest,
9
10
  resolveCarveManifest,
11
+ resolveManifestFilePath,
10
12
  updateCarveManifest,
11
13
  writeCarveManifest,
12
14
  type CarveManifest,
@@ -89,6 +91,56 @@ describe("carve manifest", () => {
89
91
  });
90
92
  });
91
93
 
94
+ test("records file paths relative to its own directory, so the tree can move (#2039)", () => {
95
+ withDir((dir) => {
96
+ const m = manifest("aws_s3_bucket.assets");
97
+ m.emit = { source: "tfstate", files: [join(dir, "src", "assets.ts")], at: "t" };
98
+ m.bridge = {
99
+ written: [join(dir, "aws_s3_bucket-assets-runbook.md"), join(dir, "..", "api.tf")],
100
+ appliedInPlace: false,
101
+ patch: join(dir, "aws_s3_bucket-assets-bridge.patch"),
102
+ at: "t",
103
+ };
104
+ const path = writeCarveManifest(dir, m);
105
+ const read = readCarveManifest(path)!;
106
+ expect(read.version).toBe(CARVE_MANIFEST_VERSION);
107
+ expect(read.emit!.files).toEqual([join("src", "assets.ts")]);
108
+ expect(read.bridge!.written).toEqual(["aws_s3_bucket-assets-runbook.md", join("..", "api.tf")]);
109
+ expect(read.bridge!.patch).toBe("aws_s3_bucket-assets-bridge.patch");
110
+ // `from`/`statePath` locate the estate, a tree the manifest does not
111
+ // travel with — they stay absolute.
112
+ expect(read.from).toBe("/estate");
113
+ });
114
+ });
115
+
116
+ test("resolveManifestFilePath joins relative entries and passes absolute (v1) entries through", () => {
117
+ expect(resolveManifestFilePath("/moved/carveout", join("src", "assets.ts"))).toBe(join("/moved/carveout", "src", "assets.ts"));
118
+ expect(resolveManifestFilePath("/moved/carveout", "/original/carveout/src/assets.ts")).toBe("/original/carveout/src/assets.ts");
119
+ });
120
+
121
+ test("reads a version-1 manifest (absolute paths) and migrates it on update", () => {
122
+ withDir((dir) => {
123
+ const path = carveManifestPath(dir, "aws_s3_bucket.assets");
124
+ const v1: CarveManifest = {
125
+ ...manifest("aws_s3_bucket.assets"),
126
+ version: 1,
127
+ emit: { source: "tfstate", files: [join(dir, "src", "assets.ts")], at: "t" },
128
+ };
129
+ writeFileSync(path, JSON.stringify(v1, null, 2) + "\n");
130
+ // Readable as-is, absolute paths intact.
131
+ expect(readCarveManifest(path)!.emit!.files).toEqual([join(dir, "src", "assets.ts")]);
132
+
133
+ // Any update rewrites the whole manifest to the version-2 contract.
134
+ updateCarveManifest(dir, "aws_s3_bucket.assets", {
135
+ bridge: { written: [join(dir, "runbook.md")], appliedInPlace: false, at: "t" },
136
+ });
137
+ const migrated = readCarveManifest(path)!;
138
+ expect(migrated.version).toBe(CARVE_MANIFEST_VERSION);
139
+ expect(migrated.emit!.files).toEqual([join("src", "assets.ts")]);
140
+ expect(migrated.bridge!.written).toEqual(["runbook.md"]);
141
+ });
142
+ });
143
+
92
144
  test("update patches an existing manifest and no-ops on a missing one", () => {
93
145
  withDir((dir) => {
94
146
  expect(updateCarveManifest(dir, "aws_s3_bucket.assets", { bridge: { written: [], appliedInPlace: false, at: "t" } })).toBeUndefined();