Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 30 additions & 6 deletions CLI.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# 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? |
|---|---|---|
| `reconcile` | Diff desired vs live per cycle, guardrail-check, print the plan (`dry-run`) or apply it. | Only with `--mode apply`. |
| `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
Expand Down Expand Up @@ -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 <environment> [--dir <path>] [--format paths|ruleset] [--name <ruleset>]
```

Reads `chant workspace status <environment> --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 <name>` | **required** | The environment chant reads ledgers for. |
| `--dir <path>` | `.` | 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 <ruleset>` | `chant-lifecycle` | The ruleset's name, its identity key in the repo. |

The ruleset targets `refs/heads/<lifecycle.ref>` 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.<repo>.rulesets` in the policy file and the
`rulesets` cycle reconciles it like any other ruleset.
136 changes: 131 additions & 5 deletions action/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <environment>");
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(
[
Expand All @@ -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> Path to governance config file (YAML or JSON).",
Expand Down Expand Up @@ -255031,6 +255151,12 @@ function printUsage() {
" --out <path> Write the file here instead of stdout.",
" --check With --out: exit 4 when the file differs. Writes nothing.",
"",
"Flags (lifecycle):",
" --env <name> Environment passed to chant workspace status. Required.",
" --dir <path> Where chant starts looking for the workspace (default: .).",
" --format paths|ruleset Ledger paths (default) or the branch ruleset as JSON.",
" --name <ruleset> Ruleset name (default: chant-lifecycle).",
"",
"Exit codes:",
" 0 Success.",
" 1 Guardrail block (apply mode, override not set).",
Expand Down
82 changes: 81 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> Path to the governance config file (YAML/JSON).
Expand Down Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1274,6 +1281,72 @@ async function runCodeowners(argv: string[]): Promise<void> {
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 <environment>");
return args;
}

/**
* Run `lifecycle`: read the branch name and ledger paths from
* `chant workspace status <env> --json`. `--format ruleset` prints the ruleset
* for the branch as JSON, to put under `repos.<repo>.rulesets`.
*/
async function runLifecycle(argv: string[]): Promise<void> {
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(
[
Expand All @@ -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> Path to governance config file (YAML or JSON).",
Expand Down Expand Up @@ -1319,6 +1393,12 @@ function printUsage() {
" --out <path> Write the file here instead of stdout.",
" --check With --out: exit 4 when the file differs. Writes nothing.",
"",
"Flags (lifecycle):",
" --env <name> Environment passed to chant workspace status. Required.",
" --dir <path> Where chant starts looking for the workspace (default: .).",
" --format paths|ruleset Ledger paths (default) or the branch ruleset as JSON.",
" --name <ruleset> Ruleset name (default: chant-lifecycle).",
"",
"Exit codes:",
" 0 Success.",
" 1 Guardrail block (apply mode, override not set).",
Expand Down
17 changes: 17 additions & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Loading
Loading