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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ releases before `0.4.1` are not recorded here.

## Unreleased

- New `reconcile --plan-json <file>` writes the change sets a run planned, one per
cycle and org, as a JSON array of chant's reconcile `ChangeSet`. chant's
change-set document reads it (`readChangeSetPart` with `planner: "warden"`).
It cannot be combined with `--mode apply`. `runReconcile` also returns them
as `changeSets`.
- New `checks` subcommand reads each member's generated pipeline job names from
`chant workspace ls --json` and prints the ruleset that requires them as
status checks. The chant range moves to `^0.101.0`, which publishes the names.
Expand Down
3 changes: 2 additions & 1 deletion CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ shape exits 2 with the offending field path.
```
github-warden reconcile --config <path> [auth flags] [--mode dry-run|apply]
[--cycles a,b,c] [--allow-guardrail-override]
[--removal-cap-fraction <value>]
[--removal-cap-fraction <value>] [--plan-json <file>]
```

| Flag | Default | Meaning |
Expand All @@ -44,6 +44,7 @@ github-warden reconcile --config <path> [auth flags] [--mode dry-run|apply]
| `--installation-id-env <VAR>` | — | Env var holding the installation ID. |
| `--allow-guardrail-override` | off | Apply even when guardrails trip. |
| `--removal-cap-fraction <value>` | `0.25` | `removalDeltaCap` threshold: the max fraction of any one resource type's live managed entries the plan may delete. Must be in (0,1]; anything else exits 2. See [the canonical cap description in POLICY.md](POLICY.md#what-the-policy-does-not-declare). |
| `--plan-json <file>` | — | Write the change sets the run planned (one per cycle and org, a JSON array of chant's reconcile `ChangeSet`) to the file, for chant's change-set document (`readChangeSetPart` with `planner: "warden"`). Nothing is applied: combining it with `--mode apply` exits 2. |

Deletes come from the policy, not from a flag: a live resource missing from
the policy is planned for deletion only in an org whose policy declares
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ npx @intentius/github-warden reconcile --config .github/governance.yml --token-e
### `reconcile`

```
github-warden reconcile --config <path> [auth] [--mode dry-run|apply] [--cycles a,b,c] [--allow-guardrail-override] [--removal-cap-fraction <value>]
github-warden reconcile --config <path> [auth] [--mode dry-run|apply] [--cycles a,b,c] [--allow-guardrail-override] [--removal-cap-fraction <value>] [--plan-json <file>]
```

| Flag | Meaning |
Expand All @@ -88,6 +88,7 @@ github-warden reconcile --config <path> [auth] [--mode dry-run|apply] [--cycles
| `--cycles <name[,name...]>` | Subset of cycles to run (default: all). |
| `--allow-guardrail-override` | Apply even when guardrails trip. |
| `--removal-cap-fraction <value>` | `removalDeltaCap` threshold in (0,1] (default 0.25). |
| `--plan-json <file>` | Write the planned reconcile change sets (one per cycle and org) as JSON for chant's change-set document. Dry-run only. |

### `audit`

Expand Down
25 changes: 25 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
* --removal-cap-fraction <v> Max fraction of live managed entries the plan
* may delete per resource type, in (0,1].
* Default: 0.25.
* --plan-json <file> Write the reconcile ChangeSets this run planned
* (one per cycle and org) as a JSON array, for
* chant's change-set document. Dry-run only.
*
* Auth modes (mutually exclusive, precedence: token-env > app-id-env):
* 1. --token-env GH_TOKEN
Expand Down Expand Up @@ -77,6 +80,8 @@ export interface ReconcileArgs {
allowGuardrailOverride: boolean;
/** removalDeltaCap threshold in (0,1]; undefined keeps chant's default. */
removalCapFraction: number | undefined;
/** File to write the planned reconcile ChangeSets to, as JSON. */
planJson: string | undefined;
}

/**
Expand Down Expand Up @@ -113,6 +118,7 @@ export function parseReconcileArgs(argv: string[]): ReconcileArgs {
tokenEnv: undefined,
allowGuardrailOverride: false,
removalCapFraction: undefined,
planJson: undefined,
};

const knownFlags = new Set([
Expand All @@ -124,6 +130,7 @@ export function parseReconcileArgs(argv: string[]): ReconcileArgs {
"--token-env",
"--allow-guardrail-override",
"--removal-cap-fraction",
"--plan-json",
]);

let i = 0;
Expand Down Expand Up @@ -206,12 +213,22 @@ export function parseReconcileArgs(argv: string[]): ReconcileArgs {
args.removalCapFraction = fraction;
break;
}
case "--plan-json": {
const val = argv[++i];
if (val === undefined || val.startsWith("--"))
throw new CliError(2, "--plan-json requires a value");
args.planJson = val;
break;
}
}
i++;
}

// Validate required flags.
if (!args.config) throw new CliError(2, "--config is required");
if (args.planJson !== undefined && args.mode === "apply") {
throw new CliError(2, "--plan-json writes a plan without applying it; it cannot be combined with --mode apply");
}

const hasTokenAuth = !!args.tokenEnv;
const hasAppAuth = !!(args.appIdEnv && args.installationIdEnv);
Expand Down Expand Up @@ -666,6 +683,14 @@ async function main(argv: string[] = process.argv.slice(2)) {

// ── Output ────────────────────────────────────────────────────────────────

if (args.planJson !== undefined) {
try {
writeFileSync(args.planJson, `${JSON.stringify(result.changeSets, null, 2)}\n`, "utf-8");
} catch (err) {
die(3, `failed to write plan to "${args.planJson}": ${errMsg(err)}`);
}
}

// Print plan summary for every cycle that ran.
for (const cr of result.cycles) {
process.stdout.write(`\n=== ${cr.name} @ ${cr.org} ===\n`);
Expand Down
25 changes: 25 additions & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ describe("parseReconcileArgs", () => {
).toBe(1);
});

it("parses --plan-json, defaulting to undefined, and refuses it with --mode apply", () => {
const base = ["--config", "g.yml", "--token-env", "GH_TOKEN"];
expect(parseReconcileArgs(base).planJson).toBeUndefined();
expect(parseReconcileArgs([...base, "--plan-json", "plan.json"]).planJson).toBe("plan.json");
expect(() => parseReconcileArgs([...base, "--plan-json"])).toThrow(
expect.objectContaining({ code: 2 }),
);
expect(() => parseReconcileArgs([...base, "--mode", "apply", "--plan-json", "plan.json"])).toThrow(
expect.objectContaining({ code: 2 }),
);
});

it("throws code 2 for a --removal-cap-fraction outside (0, 1]", () => {
const base = ["--config", "g.yml", "--token-env", "GH_TOKEN"];
for (const bad of ["0", "-0.5", "1.5", "abc"]) {
Expand Down Expand Up @@ -323,6 +335,19 @@ describe("runReconcile integration (mocked client)", () => {
}
});

it("returns the change set it planned, one per cycle and org", async () => {
const result = await runReconcile({
config: makeSimpleConfig(),
client: makeMockClient(),
cycles: [makeCreateCycle()],
mode: "dry-run",
});

expect(result.changeSets).toHaveLength(result.cycles.length);
expect(result.changeSets.map((cs) => cs.org)).toEqual(result.cycles.map((cr) => cr.org));
expect(result.changeSets.flatMap((cs) => cs.entries).length).toBeGreaterThan(0);
});

it("apply mode applies entries and records them", async () => {
const cycle = makeCreateCycle();
const result = await runReconcile({
Expand Down
18 changes: 13 additions & 5 deletions src/reconcile/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { GuardrailConfig } from "./guardrails.js";
import { drainNotes } from "../cycles/notes.js";
import { runReconcile as coreRunReconcile } from "./core.js";
import type {
ChangeSet,
Cycle as CoreCycle,
ReconcileResult,
} from "./core.js";
Expand Down Expand Up @@ -89,10 +90,14 @@ function ownedPredicate(
*/
export async function runReconcile<TScope = unknown>(
opts: RunReconcileOptions<TScope>,
): Promise<ReconcileResult> {
): Promise<ReconcileResult & { changeSets: ChangeSet[] }> {
// Clear notes a previous (crashed/errored) run may have left behind.
drainNotes();

// Every change set the run diffed, one per cycle and org, in run order.
// This is the plan chant's change-set document reads (`--plan-json`).
const changeSets: ChangeSet[] = [];

const result = await coreRunReconcile<AppClient, OrgConfig, LiveOrgState, TScope>({
client: opts.client,
scopes: opts.config.orgs,
Expand All @@ -103,12 +108,15 @@ export async function runReconcile<TScope = unknown>(
// removalDeltaCap) on each change set it returns, so every guardrail
// evaluation carries its own live counts — no side channel between the
// diff and guardrail callbacks.
diff: (scopeId, desired, live, dopts) =>
diff(scopeId, desired, live, {
diff: (scopeId, desired, live, dopts) => {
const changeSet = diff(scopeId, desired, live, {
...dopts,
isOwned: dopts.isOwned ?? ownedPredicate(opts.config.orgs[scopeId]?.owned),
nowMs: dopts.nowMs ?? Date.now(),
}),
});
changeSets.push(changeSet);
return changeSet;
},
guardrails: (changeSet, live) =>
runGuardrails(changeSet, live, opts.guardrails ?? {}),
diffOptions: opts.diffOptions,
Expand All @@ -126,5 +134,5 @@ export async function runReconcile<TScope = unknown>(
if (cr) cr.plan = `${cr.plan}\n${n.note}`;
}

return result;
return { ...result, changeSets };
}
Loading