From 586001faf4cc2a3075712542f230eb93fe0e979c Mon Sep 17 00:00:00 2001 From: lex00 Date: Wed, 30 Sep 2026 19:01:18 -0600 Subject: [PATCH] feat: read the chant/lifecycle ledger paths and ruleset from the status contract (#62) Add a lifecycle subcommand that reads the branch name and each member's release and gate ledger paths from chant workspace status --json, and prints the ruleset that protects the branch (no deletion, no force push). --- CHANGELOG.md | 3 + CLI.md | 36 +++++++-- action/index.mjs | 136 ++++++++++++++++++++++++++++++-- src/cli.ts | 82 ++++++++++++++++++- src/cli/cli.test.ts | 17 ++++ src/workspace/lifecycle.test.ts | 95 ++++++++++++++++++++++ src/workspace/lifecycle.ts | 97 +++++++++++++++++++++++ src/workspace/members.ts | 27 +++++-- 8 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 src/workspace/lifecycle.test.ts create mode 100644 src/workspace/lifecycle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ef806..880490f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ releases before `0.4.1` are not recorded here. ## Unreleased +- New `lifecycle` subcommand reads the `chant/lifecycle` branch name and each member's + ledger paths from `chant workspace status --json`, and prints the ruleset that + protects the branch (no deletion, no force push) for the `rulesets` cycle. - New `codeowners` subcommand generates CODEOWNERS with one rule per member of a chant workspace. Members are read through chant's read contract (`chant workspace ls --json`), and owners are declared by member name in an diff --git a/CLI.md b/CLI.md index 64d8734..364b977 100644 --- a/CLI.md +++ b/CLI.md @@ -1,9 +1,9 @@ # CLI reference -The `github-warden` binary has four subcommands. `reconcile`, `audit` and +The `github-warden` binary has five subcommands. `reconcile`, `audit` and `report` load the policy file (`--config`) and authenticate the same way; they -differ in what they do with live GitHub state. `codeowners` reads a chant -workspace from disk and touches neither GitHub nor the policy file. +differ in what they do with live GitHub state. `codeowners` and `lifecycle` read +a chant workspace from disk and touch neither GitHub nor the policy file. | Subcommand | What it does | Mutates? | |---|---|---| @@ -11,6 +11,7 @@ workspace from disk and touches neither GitHub nor the policy file. | `audit` | Run chant's posture-audit engine over every repo declared in the config. | Never. | | `report` | Run cycles in dry-run, optionally add audit and identity passes, print a compliance snapshot, optionally write a JSON artifact. | Never. | | `codeowners` | Generate CODEOWNERS from the members of a chant workspace. | Only the `--out` file, and not with `--check`. | +| `lifecycle` | Show the `chant/lifecycle` ledger paths, or print the ruleset that protects the branch. | Never. | `github-warden --help` (or no arguments, or `--help` after a subcommand) prints usage; `github-warden --version` prints the version (inlined from @@ -172,6 +173,29 @@ missing (reason `dir-missing`) is skipped with a note on stderr. A name in the owners file that the workspace has no member for exits 2. A failed chant read exits 3. -Not yet covered: required status checks per member pipeline and a ruleset for -the `chant/lifecycle` branch. Both need the generated job names, which no -contract document carries yet (INTENTIUS/chant#3050). +Not yet covered: required status checks per member pipeline. They need the +generated job names, which no contract document carries yet +(INTENTIUS/chant#3050). + +## `lifecycle` + +``` +github-warden lifecycle --env [--dir ] [--format paths|ruleset] [--name ] +``` + +Reads `chant workspace status --json`, which needs chant 0.81 or +newer. The branch name comes from `lifecycle.ref` in that document, and each +member's release ledger and gate ledger paths from `members[].environments[].ledger.path` +and `members[].gateLedger.path`. Nothing is hard-coded. + +| Flag | Default | Meaning | +|---|---|---| +| `--env ` | **required** | The environment chant reads ledgers for. | +| `--dir ` | `.` | Where chant starts looking for the workspace declaration. | +| `--format paths\|ruleset` | `paths` | `paths` prints the branch and the ledger paths. `ruleset` prints the ruleset as JSON. | +| `--name ` | `chant-lifecycle` | The ruleset's name, its identity key in the repo. | + +The ruleset targets `refs/heads/` with the rules `deletion` and +`non_fast_forward`. The branch can't be deleted and its history can't be rewritten. Appending +commits stays allowed, and that is how chant records releases and gate facts. Put the output under `repos..rulesets` in the policy file and the +`rulesets` cycle reconciles it like any other ruleset. diff --git a/action/index.mjs b/action/index.mjs index 8262a6f..803731f 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -254025,19 +254025,76 @@ function chantLauncher() { } return null; } -async function readWorkspace(dir, run4 = defaultRunner) { +async function runChantJson(dir, args, what, run4 = defaultRunner) { let result; try { - result = await run4(chantLauncher() ?? "chant", ["workspace", "ls", "--json"], dir); + result = await run4(chantLauncher() ?? "chant", args, dir); } catch (err) { throw new WorkspaceReadError(`could not run chant: ${err instanceof Error ? err.message : String(err)}`); } if (result.stdout.trim() === "") { throw new WorkspaceReadError( - `\`chant workspace ls --json\` printed nothing (exit ${result.code}); chant 0.81.0 or newer is required. ${result.stderr.trim()}`.trim() + `\`${what}\` printed nothing (exit ${result.code}); chant 0.81.0 or newer is required. ${result.stderr.trim()}`.trim() ); } - return parseLsDocument(result.stdout); + return result.stdout; +} +async function readWorkspace(dir, run4 = defaultRunner) { + return parseLsDocument(await runChantJson(dir, ["workspace", "ls", "--json"], "chant workspace ls --json", run4)); +} + +// src/workspace/lifecycle.ts +function ledger(o) { + if (!o || typeof o !== "object") return null; + const l = o; + if (typeof l["path"] !== "string") return null; + return { path: l["path"], layout: l["layout"] === "members" ? "members" : "flat" }; +} +function parseStatusDocument(stdout) { + let doc; + try { + doc = JSON.parse(stdout); + } catch { + throw new WorkspaceReadError("`chant workspace status --json` did not print JSON"); + } + const d = doc ?? {}; + if (d["contract"] !== READ_CONTRACT) { + throw new WorkspaceReadError( + `read contract ${String(d["contract"])} is not supported, this warden reads contract ${READ_CONTRACT}` + ); + } + if (d["error"] && typeof d["error"] === "object") { + const e = d["error"]; + throw new WorkspaceReadError(`chant could not read the workspace: ${String(e.code)}: ${String(e.message)}`); + } + const lc = d["lifecycle"]; + if (!lc || typeof lc["ref"] !== "string" || !Array.isArray(d["members"])) { + throw new WorkspaceReadError("`chant workspace status --json` document lacks lifecycle or members"); + } + const members = d["members"].map((m, i) => { + if (typeof m["name"] !== "string") throw new WorkspaceReadError(`members[${i}] lacks a name`); + const envs = Array.isArray(m["environments"]) ? m["environments"] : []; + const releases = []; + for (const e of envs) { + const l = ledger(e["ledger"]); + if (l && typeof e["env"] === "string") releases.push({ env: e["env"], ...l }); + } + return { name: m["name"], releases, gates: ledger(m["gateLedger"]) }; + }); + return { ref: lc["ref"], commit: typeof lc["commit"] === "string" ? lc["commit"] : null, members }; +} +async function readLifecycle(dir, env2, run4 = defaultRunner) { + const out = await runChantJson(dir, ["workspace", "status", env2, "--json"], `chant workspace status ${env2} --json`, run4); + return parseStatusDocument(out); +} +function lifecycleRuleset(ref, name = "chant-lifecycle") { + return { + name, + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: [`refs/heads/${ref}`], exclude: [] } }, + rules: [{ type: "deletion" }, { type: "non_fast_forward" }] + }; } // src/workspace/codeowners.ts @@ -254476,8 +254533,12 @@ async function main(argv = process.argv.slice(2)) { await runCodeowners(argv.slice(1)); return; } + if (subcommand === "lifecycle") { + await runLifecycle(argv.slice(1)); + return; + } if (subcommand !== "reconcile") { - die(2, `unknown subcommand: ${subcommand}. Did you mean "reconcile", "audit", "report", or "codeowners"?`); + die(2, `unknown subcommand: ${subcommand}. Did you mean "reconcile", "audit", "report", "codeowners", or "lifecycle"?`); } let args; try { @@ -254986,6 +255047,64 @@ async function runCodeowners(argv) { `); process.exit(0); } +function parseLifecycleArgs(argv) { + const args = { env: "", dir: ".", format: "paths", name: "chant-lifecycle" }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const value = () => { + const v = argv[++i]; + if (!v || v.startsWith("--")) throw new CliError(2, `${flag} requires a value`); + return v; + }; + switch (flag) { + case "--env": + args.env = value(); + break; + case "--dir": + args.dir = value(); + break; + case "--name": + args.name = value(); + break; + case "--format": { + const f = value(); + if (f !== "paths" && f !== "ruleset") throw new CliError(2, `--format must be paths or ruleset, got ${f}`); + args.format = f; + break; + } + default: + throw new CliError(2, `unknown flag for lifecycle: ${flag}`); + } + } + if (!args.env) throw new CliError(2, "lifecycle requires --env "); + return args; +} +async function runLifecycle(argv) { + let args; + try { + args = parseLifecycleArgs(argv); + } catch (err) { + if (err instanceof CliError) die(err.code, err.message); + throw err; + } + let lc; + try { + lc = await readLifecycle(args.dir, args.env); + } catch (err) { + die(err instanceof WorkspaceReadError ? 3 : 2, errMsg2(err)); + } + if (args.format === "ruleset") { + process.stdout.write(JSON.stringify(lifecycleRuleset(lc.ref, args.name), null, 2) + "\n"); + process.exit(0); + } + const lines = [`branch ${lc.ref}${lc.commit ? ` at ${lc.commit.slice(0, 8)}` : " (not in this checkout)"}`, ""]; + for (const m of lc.members) { + for (const r of m.releases) lines.push(`${m.name} releases (${r.env}) ${r.path}`); + if (m.gates) lines.push(`${m.name} gates ${m.gates.path}`); + } + process.stdout.write(lines.join("\n") + "\n"); + process.exit(0); +} function printUsage() { process.stdout.write( [ @@ -254996,6 +255115,7 @@ function printUsage() { " audit Audit managed repos for security/correctness posture.", " report Aggregate cycle drift (+ optional audit) into a compliance snapshot.", " codeowners Generate CODEOWNERS from a chant workspace's members.", + " lifecycle Show the chant/lifecycle ledger paths, or print its ruleset.", "", "Flags (reconcile):", " --config Path to governance config file (YAML or JSON).", @@ -255031,6 +255151,12 @@ function printUsage() { " --out Write the file here instead of stdout.", " --check With --out: exit 4 when the file differs. Writes nothing.", "", + "Flags (lifecycle):", + " --env Environment passed to chant workspace status. Required.", + " --dir Where chant starts looking for the workspace (default: .).", + " --format paths|ruleset Ledger paths (default) or the branch ruleset as JSON.", + " --name Ruleset name (default: chant-lifecycle).", + "", "Exit codes:", " 0 Success.", " 1 Guardrail block (apply mode, override not set).", diff --git a/src/cli.ts b/src/cli.ts index cd3bfa0..dfcd48c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ * reconcile Load config, build an authed client, run selected cycles. * audit Run chant's posture-audit engine over all managed repos. * codeowners Generate CODEOWNERS from a chant workspace's members. + * lifecycle Show the chant/lifecycle ledger paths and its ruleset. * * Flag contract (must stay in sync with emit/pipeline.ts): * --config Path to the governance config file (YAML/JSON). @@ -57,6 +58,7 @@ import type { Cycle, ReconcileResult } from "./reconcile/runner.js"; import { buildComplianceReport, renderComplianceReport, complianceArtifact } from "./report/compliance.js"; import { buildIdentityReport, type RawInstallation } from "./report/identity.js"; import { readWorkspace, WorkspaceReadError } from "./workspace/members.js"; +import { readLifecycle, lifecycleRuleset } from "./workspace/lifecycle.js"; import { buildCodeowners, parseOwnersConfig, OwnersConfigError } from "./workspace/codeowners.js"; // --------------------------------------------------------------------------- @@ -576,8 +578,13 @@ async function main(argv: string[] = process.argv.slice(2)) { return; } + if (subcommand === "lifecycle") { + await runLifecycle(argv.slice(1)); + return; + } + if (subcommand !== "reconcile") { - die(2, `unknown subcommand: ${subcommand}. Did you mean "reconcile", "audit", "report", or "codeowners"?`); + die(2, `unknown subcommand: ${subcommand}. Did you mean "reconcile", "audit", "report", "codeowners", or "lifecycle"?`); } let args: ReconcileArgs; @@ -1274,6 +1281,72 @@ async function runCodeowners(argv: string[]): Promise { process.exit(0); } +export interface LifecycleArgs { + env: string; + dir: string; + format: "paths" | "ruleset"; + name: string; +} + +/** Parse lifecycle argv (everything after the `lifecycle` subcommand). */ +export function parseLifecycleArgs(argv: string[]): LifecycleArgs { + const args: LifecycleArgs = { env: "", dir: ".", format: "paths", name: "chant-lifecycle" }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]!; + const value = (): string => { + const v = argv[++i]; + if (!v || v.startsWith("--")) throw new CliError(2, `${flag} requires a value`); + return v; + }; + switch (flag) { + case "--env": args.env = value(); break; + case "--dir": args.dir = value(); break; + case "--name": args.name = value(); break; + case "--format": { + const f = value(); + if (f !== "paths" && f !== "ruleset") throw new CliError(2, `--format must be paths or ruleset, got ${f}`); + args.format = f; + break; + } + default: throw new CliError(2, `unknown flag for lifecycle: ${flag}`); + } + } + if (!args.env) throw new CliError(2, "lifecycle requires --env "); + return args; +} + +/** + * Run `lifecycle`: read the branch name and ledger paths from + * `chant workspace status --json`. `--format ruleset` prints the ruleset + * for the branch as JSON, to put under `repos..rulesets`. + */ +async function runLifecycle(argv: string[]): Promise { + let args: LifecycleArgs; + try { + args = parseLifecycleArgs(argv); + } catch (err) { + if (err instanceof CliError) die(err.code, err.message); + throw err; + } + let lc; + try { + lc = await readLifecycle(args.dir, args.env); + } catch (err) { + die(err instanceof WorkspaceReadError ? 3 : 2, errMsg(err)); + } + if (args.format === "ruleset") { + process.stdout.write(JSON.stringify(lifecycleRuleset(lc.ref, args.name), null, 2) + "\n"); + process.exit(0); + } + const lines = [`branch ${lc.ref}${lc.commit ? ` at ${lc.commit.slice(0, 8)}` : " (not in this checkout)"}`, ""]; + for (const m of lc.members) { + for (const r of m.releases) lines.push(`${m.name} releases (${r.env}) ${r.path}`); + if (m.gates) lines.push(`${m.name} gates ${m.gates.path}`); + } + process.stdout.write(lines.join("\n") + "\n"); + process.exit(0); +} + function printUsage() { process.stdout.write( [ @@ -1284,6 +1357,7 @@ function printUsage() { " audit Audit managed repos for security/correctness posture.", " report Aggregate cycle drift (+ optional audit) into a compliance snapshot.", " codeowners Generate CODEOWNERS from a chant workspace's members.", + " lifecycle Show the chant/lifecycle ledger paths, or print its ruleset.", "", "Flags (reconcile):", " --config Path to governance config file (YAML or JSON).", @@ -1319,6 +1393,12 @@ function printUsage() { " --out Write the file here instead of stdout.", " --check With --out: exit 4 when the file differs. Writes nothing.", "", + "Flags (lifecycle):", + " --env Environment passed to chant workspace status. Required.", + " --dir Where chant starts looking for the workspace (default: .).", + " --format paths|ruleset Ledger paths (default) or the branch ruleset as JSON.", + " --name Ruleset name (default: chant-lifecycle).", + "", "Exit codes:", " 0 Success.", " 1 Guardrail block (apply mode, override not set).", diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 012f0ec..97cb4a7 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -478,3 +478,20 @@ describe("parseCodeownersArgs", () => { expect(() => parseCodeownersArgs(["--owners", "o.json", "--bogus"])).toThrow(/unknown flag/); }); }); + +describe("parseLifecycleArgs", () => { + it("parses env, dir, format and name", async () => { + const { parseLifecycleArgs } = await import("../cli.js"); + expect(parseLifecycleArgs(["--env", "prod", "--format", "ruleset", "--name", "lc", "--dir", "ws"])).toEqual({ + env: "prod", + dir: "ws", + format: "ruleset", + name: "lc", + }); + }); + it("requires --env and a known format", async () => { + const { parseLifecycleArgs } = await import("../cli.js"); + expect(() => parseLifecycleArgs([])).toThrow(/--env/); + expect(() => parseLifecycleArgs(["--env", "p", "--format", "x"])).toThrow(/paths or ruleset/); + }); +}); diff --git a/src/workspace/lifecycle.test.ts b/src/workspace/lifecycle.test.ts new file mode 100644 index 0000000..42243fa --- /dev/null +++ b/src/workspace/lifecycle.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadGovernanceConfig } from "../config/load.js"; +import { lifecycleRuleset, parseStatusDocument, readLifecycle } from "./lifecycle.js"; + +const status = (over: Record = {}) => + JSON.stringify({ + contract: 1, + chant: "0.100.0", + env: "prod", + compareTo: null, + lifecycle: { ref: "chant/lifecycle", commit: "a".repeat(40) }, + workspace: { name: "demo", root: ".", file: "chant.workspace.json" }, + members: [ + { + name: "api", + dir: "packages/api", + environments: [{ env: "prod", ledger: { layout: "members", path: "_members/api/prod/releases.jsonl", shared: false }, releases: [], reason: null }], + gateLedger: { layout: "members", path: "_members/api/_gates", shared: false, malformed: 0, reason: null }, + }, + { + name: "docs", + dir: "docs", + environments: [{ env: "prod", ledger: { layout: "flat", path: "prod/releases.jsonl", shared: true }, releases: [], reason: null }], + gateLedger: { layout: "flat", path: "_gates", shared: true, malformed: 0, reason: null }, + }, + ], + summary: {}, + ...over, + }); + +describe("parseStatusDocument", () => { + it("reads the branch, its tip and each member's ledger paths", () => { + const lc = parseStatusDocument(status()); + expect(lc.ref).toBe("chant/lifecycle"); + expect(lc.commit).toBe("a".repeat(40)); + expect(lc.members[0]).toEqual({ + name: "api", + releases: [{ env: "prod", layout: "members", path: "_members/api/prod/releases.jsonl" }], + gates: { layout: "members", path: "_members/api/_gates" }, + }); + expect(lc.members[1]!.releases[0]!.layout).toBe("flat"); + }); + it("refuses another contract, a failure and a bad document", () => { + expect(() => parseStatusDocument(status({ contract: 2 }))).toThrow(/contract 2/); + expect(() => parseStatusDocument(JSON.stringify({ contract: 1, error: { code: "environment-invalid", message: "x" } }))).toThrow(/environment-invalid/); + expect(() => parseStatusDocument(status({ lifecycle: undefined }))).toThrow(/lacks lifecycle/); + expect(() => parseStatusDocument("no")).toThrow(/did not print JSON/); + }); + it("runs status for the environment", async () => { + const calls: unknown[] = []; + await readLifecycle("/r", "prod", async (_c, args, cwd) => { + calls.push([args, cwd]); + return { stdout: status(), stderr: "", code: 0 }; + }); + expect(calls).toEqual([[["workspace", "status", "prod", "--json"], "/r"]]); + }); +}); + +describe("lifecycleRuleset", () => { + it("blocks deletion and force pushes on the branch the contract names", () => { + expect(lifecycleRuleset("chant/lifecycle")).toEqual({ + name: "chant-lifecycle", + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: ["refs/heads/chant/lifecycle"], exclude: [] } }, + rules: [{ type: "deletion" }, { type: "non_fast_forward" }], + }); + }); +}); + +describe("the ruleset in a governance config", () => { + it("passes the config loader as a repo ruleset", () => { + const cfg = loadGovernanceConfig({ orgs: { acme: { repos: { site: { rulesets: [lifecycleRuleset("chant/lifecycle")] } } } } }); + expect(cfg.orgs["acme"]!.repos!["site"]!.rulesets![0]!.rules).toHaveLength(2); + }); +}); + +describe("against a real chant workspace", () => { + it("reads the lifecycle from `chant workspace status --json`", async () => { + const dir = mkdtempSync(join(tmpdir(), "warden-lc-")); + execFileSync("git", ["init", "-q"], { cwd: dir }); + mkdirSync(join(dir, "docs")); + writeFileSync( + join(dir, "chant.workspace.json"), + JSON.stringify({ schema: 1, name: "demo", members: [{ name: "docs", kind: "other", dir: "docs", because: "the docs" }] }), + ); + const lc = await readLifecycle(dir, "prod"); + expect(lc.ref).toBe("chant/lifecycle"); + expect(lc.members[0]!.releases[0]!.path).toBe("prod/releases.jsonl"); + }, 60_000); +}); diff --git a/src/workspace/lifecycle.ts b/src/workspace/lifecycle.ts new file mode 100644 index 0000000..c991875 --- /dev/null +++ b/src/workspace/lifecycle.ts @@ -0,0 +1,97 @@ +/** + * The chant/lifecycle branch, read from chant's read contract. + * + * `chant workspace status --json` names the branch (`lifecycle.ref`) and, + * for each member, where its release ledger and gate ledger sit on it. The + * warden reads those, and builds the ruleset that protects the branch. + */ + +import type { RulesetConfig } from "../config/types.js"; +import { READ_CONTRACT, runChantJson, WorkspaceReadError, type CommandRunner, defaultRunner } from "./members.js"; + +export interface LedgerPath { + /** Path on the lifecycle branch. */ + path: string; + layout: "members" | "flat"; +} + +export interface MemberLifecycle { + name: string; + /** One release ledger per environment the command showed. */ + releases: (LedgerPath & { env: string })[]; + /** The gate ledger directory, or null when the document gives none. */ + gates: LedgerPath | null; +} + +export interface Lifecycle { + /** The branch, as the contract names it (`lifecycle.ref`). */ + ref: string; + /** The local branch tip chant read, null when the checkout has no such branch. */ + commit: string | null; + members: MemberLifecycle[]; +} + +function ledger(o: unknown): LedgerPath | null { + if (!o || typeof o !== "object") return null; + const l = o as Record; + if (typeof l["path"] !== "string") return null; + return { path: l["path"], layout: l["layout"] === "members" ? "members" : "flat" }; +} + +/** Parse one `chant workspace status --json` document. */ +export function parseStatusDocument(stdout: string): Lifecycle { + let doc: unknown; + try { + doc = JSON.parse(stdout); + } catch { + throw new WorkspaceReadError("`chant workspace status --json` did not print JSON"); + } + const d = (doc ?? {}) as Record; + if (d["contract"] !== READ_CONTRACT) { + throw new WorkspaceReadError( + `read contract ${String(d["contract"])} is not supported, this warden reads contract ${READ_CONTRACT}`, + ); + } + if (d["error"] && typeof d["error"] === "object") { + const e = d["error"] as { code?: unknown; message?: unknown }; + throw new WorkspaceReadError(`chant could not read the workspace: ${String(e.code)}: ${String(e.message)}`); + } + const lc = d["lifecycle"] as Record | undefined; + if (!lc || typeof lc["ref"] !== "string" || !Array.isArray(d["members"])) { + throw new WorkspaceReadError("`chant workspace status --json` document lacks lifecycle or members"); + } + const members: MemberLifecycle[] = (d["members"] as Record[]).map((m, i) => { + if (typeof m["name"] !== "string") throw new WorkspaceReadError(`members[${i}] lacks a name`); + const envs = Array.isArray(m["environments"]) ? (m["environments"] as Record[]) : []; + const releases: MemberLifecycle["releases"] = []; + for (const e of envs) { + const l = ledger(e["ledger"]); + if (l && typeof e["env"] === "string") releases.push({ env: e["env"], ...l }); + } + return { name: m["name"], releases, gates: ledger(m["gateLedger"]) }; + }); + return { ref: lc["ref"], commit: typeof lc["commit"] === "string" ? lc["commit"] : null, members }; +} + +/** Run `chant workspace status --json` in `dir` and read the lifecycle from it. */ +export async function readLifecycle(dir: string, env: string, run: CommandRunner = defaultRunner): Promise { + const out = await runChantJson(dir, ["workspace", "status", env, "--json"], `chant workspace status ${env} --json`, run); + return parseStatusDocument(out); +} + +/** + * The ruleset that protects the lifecycle branch: it can't be deleted and its + * history can't be rewritten. Appending commits, which is how releases and + * gate facts are recorded, stays allowed. The result goes under + * `repos..rulesets` in the governance config, and the rulesets cycle + * reconciles it. + */ +export function lifecycleRuleset(ref: string, name = "chant-lifecycle"): RulesetConfig { + return { + name, + target: "branch", + enforcement: "active", + conditions: { ref_name: { include: [`refs/heads/${ref}`], exclude: [] } }, + rules: [{ type: "deletion" }, { type: "non_fast_forward" }], + }; +} diff --git a/src/workspace/members.ts b/src/workspace/members.ts index 5481fad..1c7fd69 100644 --- a/src/workspace/members.ts +++ b/src/workspace/members.ts @@ -128,21 +128,34 @@ export function chantLauncher(): string | null { } /** - * Run `chant workspace ls --json` in `dir` and return the workspace. - * Uses the chant installed beside the warden, else `chant` on PATH. + * Run a chant command that prints a read-contract document and return its + * stdout. A failed read still prints a JSON failure document (exit 1), so only + * empty output is an error here. `what` names the command in messages. */ -export async function readWorkspace(dir: string, run: CommandRunner = defaultRunner): Promise { +export async function runChantJson( + dir: string, + args: string[], + what: string, + run: CommandRunner = defaultRunner, +): Promise { let result; try { - result = await run(chantLauncher() ?? "chant", ["workspace", "ls", "--json"], dir); + result = await run(chantLauncher() ?? "chant", args, dir); } catch (err) { throw new WorkspaceReadError(`could not run chant: ${err instanceof Error ? err.message : String(err)}`); } - // A failed read still prints a JSON failure document, with exit code 1. if (result.stdout.trim() === "") { throw new WorkspaceReadError( - `\`chant workspace ls --json\` printed nothing (exit ${result.code}); chant 0.81.0 or newer is required. ${result.stderr.trim()}`.trim(), + `\`${what}\` printed nothing (exit ${result.code}); chant 0.81.0 or newer is required. ${result.stderr.trim()}`.trim(), ); } - return parseLsDocument(result.stdout); + return result.stdout; +} + +/** + * Run `chant workspace ls --json` in `dir` and return the workspace. + * Uses the chant installed beside the warden, else `chant` on PATH. + */ +export async function readWorkspace(dir: string, run: CommandRunner = defaultRunner): Promise { + return parseLsDocument(await runChantJson(dir, ["workspace", "ls", "--json"], "chant workspace ls --json", run)); }