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
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
{
"name": "github-warden",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Keep your GitHub org and repos in declared state — reconcile, guardrails, drift correction",
"publishConfig": {
"access": "public"
},
"bin": {
"github-warden": "./bin/github-warden.js"
},
"files": [
"bin",
"dist"
],
"scripts": {
"tsc": "tsc --noEmit",
"test": "vitest run",
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js && chmod +x dist/cli.js",
"build:action": "esbuild src/action.ts --bundle --platform=node --format=esm --define:process.env.GITHUB_WARDEN_IS_ACTION='\"1\"' --outfile=action/index.mjs"
"build:action": "esbuild src/action.ts --bundle --platform=node --format=esm --define:process.env.GITHUB_WARDEN_IS_ACTION='\"1\"' --outfile=action/index.mjs",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@intentius/chant": "^0.8.2",
Expand Down
57 changes: 53 additions & 4 deletions src/emit/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ describe("governancePipeline", () => {

test("apply job steps include apply mode", () => {
const yaml = buildYaml();
expect(yaml).toContain("--mode apply");
// The apply warden step passes mode as a `with:` input, not a CLI flag.
expect(yaml).toContain("mode: apply");
});

// ── Security constraints ───────────────────────────────────────
Expand All @@ -95,6 +96,9 @@ describe("governancePipeline", () => {
test("all external actions are SHA-pinned", () => {
const yaml = buildYaml();
// Every `uses:` line must pin to a 40-char commit SHA.
// The serializer wraps values with special chars in single quotes, and
// SHA-pinned warden steps include a `# v1` inline comment, so we strip
// trailing quote, whitespace, and any `# …` comment before matching.
const SHA_RE = /^[a-f0-9]{40}$/;
const usesLines = yaml
.split("\n")
Expand All @@ -103,8 +107,9 @@ describe("governancePipeline", () => {

expect(usesLines.length).toBeGreaterThan(0);
for (const line of usesLines) {
// Extract the ref after "@"
const ref = line.split("@")[1]?.trim();
// Extract the ref after "@", then strip trailing quote + inline comment.
const raw = line.split("@")[1] ?? "";
const ref = raw.replace(/#.*$/, "").replace(/['"\s]/g, "");
expect(ref, `Action "${line}" must be pinned to a SHA`).toMatch(SHA_RE);
}
});
Expand Down Expand Up @@ -143,7 +148,8 @@ describe("governancePipeline", () => {

test("cycles flag is forwarded to reconcile command", () => {
const yaml = buildYaml({ cycles: ["branch-protection", "team-sync"] });
expect(yaml).toContain("--cycles branch-protection,team-sync");
// Cycles are now passed as a `with:` input to the warden action.
expect(yaml).toContain("cycles: branch-protection,team-sync");
});

test("custom appIdVar is referenced in mint-token step", () => {
Expand All @@ -156,6 +162,49 @@ describe("governancePipeline", () => {
expect(yaml).toMatch(/secrets\.MY_PRIVATE_KEY/);
});

// ── Dogfood: github-warden Action reference ────────────────────

test("warden steps use intentius/github-warden SHA-pinned with # v1 comment", () => {
const yaml = buildYaml();
// Both jobs should use the warden action pinned to the v1 commit SHA.
// The serializer quotes the value (special chars), so look for the SHA
// followed by the inline comment inside single quotes.
expect(yaml).toContain(
"50db522e57c4ccdb36af932062ee38839bc1b88e # v1"
);
// Two warden steps (dry-run + apply), each appear once.
const count = (
yaml.match(/intentius\/github-warden@50db522e57c4ccdb36af932062ee38839bc1b88e/g) ?? []
).length;
expect(count).toBe(2);
});

test("warden dry-run step passes expected with: inputs including mode dry-run", () => {
const yaml = buildYaml();
// The dry-run job step must pass command, config, mode, app-id,
// installation-id, and private-key as with: inputs.
expect(yaml).toContain("command: reconcile");
expect(yaml).toContain("mode: dry-run");
expect(yaml).toContain("app-id: '${{ vars.GOVERNANCE_APP_ID }}'");
expect(yaml).toContain(
"installation-id: '${{ vars.GOVERNANCE_INSTALLATION_ID }}'"
);
expect(yaml).toMatch(/private-key:.*secrets\.GOVERNANCE_APP_PRIVATE_KEY/);
});

test("warden apply step passes mode: apply in with: inputs", () => {
const yaml = buildYaml();
expect(yaml).toContain("mode: apply");
});

test("emitted workflow has no setup-node or npm-install steps", () => {
const yaml = buildYaml();
// After switching to the native Action, the workflow must not contain
// setup-node or npm-install boilerplate.
expect(yaml).not.toContain("setup-node");
expect(yaml).not.toContain("npm install");
});

// ── Serialization ──────────────────────────────────────────────

test("serializes to YAML without error", () => {
Expand Down
111 changes: 44 additions & 67 deletions src/emit/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* - Least-privilege permissions: read-only at workflow level; write scopes
* scoped to the single job that needs them (GHA017/GHA034).
* - `timeout-minutes` on every job (GHA022).
* - Private key sourced from a secret via `with:` on the token-minting action,
* - Private key sourced from a secret via `with:` on the reconcile action,
* never interpolated directly into a `run:` script (GHA045).
*
* Follow-up: a durable Ops-schedule variant (issue to be filed) will drive the
Expand All @@ -37,16 +37,14 @@ import { Step, Job, Workflow } from "@intentius/chant-lexicon-github";
const CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";

/**
* actions/create-github-app-token v1.11.6
* https://github.com/actions/create-github-app-token/releases/tag/v1.11.6
*/
const CREATE_APP_TOKEN_SHA = "df432ceedc7162edd81cf1e418309514dbf04a74";

/**
* actions/setup-node v4.4.0
* https://github.com/actions/setup-node/releases/tag/v4.4.0
* intentius/github-warden v1
* https://github.com/intentius/github-warden/releases/tag/v1
*
* SHA-pinned to satisfy GHA029. The `# v1` comment preserves human readability
* while preventing silent tag-repoint attacks. Warden's own audit (GHA021/029)
* enforces this pattern — the emitted pipeline dogfoods it.
*/
const SETUP_NODE_SHA = "49933ea5288caeca8642d1e84afbd3f7d6820020";
const GITHUB_WARDEN_SHA = "50db522e57c4ccdb36af932062ee38839bc1b88e"; // v1

// ── Public types ───────────────────────────────────────────────────

Expand Down Expand Up @@ -92,12 +90,6 @@ export interface GovernancePipelineOptions {
*/
privateKeySecret?: string;

/**
* Node.js version to use when running the reconcile.
* Default: `"22"`.
*/
nodeVersion?: string;

/**
* Runner label.
* Default: `"ubuntu-latest"`.
Expand Down Expand Up @@ -143,12 +135,11 @@ export function governancePipeline(opts: GovernancePipelineOptions = {}) {
appIdVar = "GOVERNANCE_APP_ID",
installationIdVar = "GOVERNANCE_INSTALLATION_ID",
privateKeySecret = "GOVERNANCE_APP_PRIVATE_KEY",
nodeVersion = "22",
runsOn = "ubuntu-latest",
timeoutMinutes = 30,
} = opts;

const cycleFlag = buildCycleArgs(cycles);
const cycleArgs = buildCycleArgs(cycles);

// ── Shared steps (used in both jobs) ───────────────────────────

Expand All @@ -163,53 +154,40 @@ export function governancePipeline(opts: GovernancePipelineOptions = {}) {
},
});

const mintTokenStep = new Step({
name: "Mint GitHub App token",
id: "app-token",
// SHA-pinned to satisfy GHA029. The private key is supplied via `with:` on
// a `uses:` step — it is never interpolated into a `run:` script (GHA045).
uses: `actions/create-github-app-token@${CREATE_APP_TOKEN_SHA}`,
// ── Dry-run job (PR) ────────────────────────────────────────────
//
// Fires only on `pull_request` events that touch the config file.
// Computes the change-set and posts the plan as a PR comment.
//
// Uses the github-warden Action directly (dogfood): SHA-pinned to satisfy
// GHA029. The private key is supplied via `with:` — never interpolated into
// a `run:` script (GHA045). The Action handles token-minting internally.

const dryRunWardenStep = new Step({
name: "Dry-run reconcile",
uses: `intentius/github-warden@${GITHUB_WARDEN_SHA} # v1`,
with: {
command: "reconcile",
config: configPath,
mode: "dry-run",
"app-id": `\${{ vars.${appIdVar} }}`,
"installation-id": `\${{ vars.${installationIdVar} }}`,
"private-key": `\${{ secrets.${privateKeySecret} }}`,
...(cycleArgs ? { cycles: cycles!.join(",") } : {}),
},
});

const setupNodeStep = new Step({
name: "Setup Node.js",
// SHA-pinned to satisfy GHA029.
uses: `actions/setup-node@${SETUP_NODE_SHA}`,
with: { "node-version": nodeVersion },
});

const installStep = new Step({
name: "Install governance CLI",
run: "npm install --global github-warden",
});

// ── Dry-run job (PR) ────────────────────────────────────────────
//
// Fires only on `pull_request` events that touch the config file.
// Computes the change-set and posts the plan as a PR comment.

const dryRunStep = new Step({
name: "Dry-run reconcile + post PR comment",
// GHA045: secrets never appear inside `run:`. The minted token is an
// ephemeral installation token (not the private key) passed via env.
const postPrCommentStep = new Step({
name: "Post dry-run summary as PR comment",
// GHA045: ephemeral token sourced from steps output, not interpolated into
// a shell script with the private key.
env: {
GH_TOKEN: "${{ steps.app-token.outputs.token }}",
GOVERNANCE_INSTALLATION_ID: `\${{ vars.${installationIdVar} }}`,
GH_TOKEN: "${{ github.token }}",
},
run: [
`OUTPUT=$(npx github-warden reconcile \\`,
` --config "${configPath}" \\`,
` --token-env GH_TOKEN \\`,
` --installation-id-env GOVERNANCE_INSTALLATION_ID \\`,
` --mode dry-run${cycleFlag} 2>&1) || true`,
``,
`gh pr comment "\${{ github.event.pull_request.number }}" \\`,
` --repo "\${{ github.repository }}" \\`,
` --body "## Governance dry-run plan\\n\\n\`\`\`\\n\${OUTPUT}\\n\`\`\`"`,
` --body "## Governance dry-run plan\\n\\nSee the \\"Dry-run reconcile\\" step for the change-set."`,
].join("\n"),
});

Expand All @@ -224,27 +202,26 @@ export function governancePipeline(opts: GovernancePipelineOptions = {}) {
},
// Only run on PR events — the apply job handles schedule and dispatch.
if: "${{ github.event_name == 'pull_request' }}",
steps: [checkoutStep, mintTokenStep, setupNodeStep, installStep, dryRunStep],
steps: [checkoutStep, dryRunWardenStep, postPrCommentStep],
});

// ── Apply job (schedule / dispatch) ─────────────────────────────
//
// Fires on schedule and on `workflow_dispatch`. Applies the change-set after
// guardrails pass. Does not run on `pull_request` events.

const applyStep = new Step({
const applyWardenStep = new Step({
name: "Apply reconcile",
env: {
GH_TOKEN: "${{ steps.app-token.outputs.token }}",
GOVERNANCE_INSTALLATION_ID: `\${{ vars.${installationIdVar} }}`,
uses: `intentius/github-warden@${GITHUB_WARDEN_SHA} # v1`,
with: {
command: "reconcile",
config: configPath,
mode: "apply",
"app-id": `\${{ vars.${appIdVar} }}`,
"installation-id": `\${{ vars.${installationIdVar} }}`,
"private-key": `\${{ secrets.${privateKeySecret} }}`,
...(cycleArgs ? { cycles: cycles!.join(",") } : {}),
},
run: [
`npx github-warden reconcile \\`,
` --config "${configPath}" \\`,
` --token-env GH_TOKEN \\`,
` --installation-id-env GOVERNANCE_INSTALLATION_ID \\`,
` --mode apply${cycleFlag}`,
].join("\n"),
});

const applyJob = new Job({
Expand All @@ -256,7 +233,7 @@ export function governancePipeline(opts: GovernancePipelineOptions = {}) {
},
// Skip on PR events — the dry-run job handles those.
if: "${{ github.event_name != 'pull_request' }}",
steps: [checkoutStep, mintTokenStep, setupNodeStep, installStep, applyStep],
steps: [checkoutStep, applyWardenStep],
});

// ── Workflow ────────────────────────────────────────────────────
Expand Down
Loading