21STARK
All posts
13 min read— views

My GitHub org is a 273-line Terraform plan

Every toggle in your GitHub org settings is a production change with no plan, no diff, no reviewer. So the governance layer became code: 273 lines of HCL, two isolated states, import-first, hardened in 47 minutes, with a $30/month near-miss caught in review.

2FA enforcement off. No Dependabot. No secret scanning. Actions allowed to run anything from anywhere. That was the posture of the org whose CI authenticates to production GCP through Workload Identity Federation. I never reviewed it, because there is nothing to review. A settings page has no diff.

The fix was 273 lines of HCL. Twelve .tf files, five resources, one data source, four import {} blocks. Empty directory to hardened posture in 47 minutes: seven commits and four PRs, all on the morning of 2026-07-11 between 11:14 and 12:01 UTC.

What those 273 lines manage is narrower than it sounds. Terraform here does not manage repos. It doesn't manage branch protection. teams.tf and apps.tf contain zero resources. It manages the layer above all of that: the org settings singleton, Actions policy at both tiers, org membership, enterprise security-analysis defaults. The small set of switches that define the blast radius of everything underneath: one enterprise, one org, 45 repos (40 private, 5 public), and exactly one human member.

That layer is tiny. It's also the layer nobody reviews.

Clicking is deploying

Comic panel of a hand casually flipping a large wall-mounted toggle switch while a wave of color sweeps across a grid of dozens of identical small buildings behind it

Every toggle on the org settings page is a production change. No plan. No diff. No reviewer. The audit log records what you did after the fact; nothing gates it before. And unlike a bad deploy, a flipped org-level bool doesn't page anyone. It sits there silently rewriting policy for every repo in the fleet, until it bites.

How mine got wide open

Between 2026-06-18 and 2026-07-03 I ran about ten migration waves, moving the whole stark-* fleet from my employer's GitHub org into a personal one. Full authorship rewrite each time, git filter-repo --mailmap from fresh mirrors. The biggest single repo was 1,064 commits across 48 branches with 5 identities normalized. Mid-migration I found a committed tfplan file embedding Terraform state with plaintext generated passwords. Untrack, rotate, purge blobs. Lesson absorbed.

Two weeks of that care went into the repos. None of it went into the org holding them. At the end I had a 44-repo org (45 today) with the posture you get from clicking "create organization" and moving on. The header comment in org/settings.tf says it straight: that's weak for the repo hosting the whole stark-* fleet.

So on 2026-07-11 I wrote the governance down as code. Same morning I renamed the org, which survived only because Workload Identity Federation was keyed to the org ID, 294881062, not the slug. Org-level API calls don't redirect, so slug-keyed anything would have broken.

Two tiers, two states

Flat vector illustration of two glass bell jars on a workbench, each sealing its own small machine, with an orange key hanging beside one jar and a blue key beside the other, and a cut, capped pipe between them

The repo is 21stark, and its structure is the first decision worth stealing. GitHub gives you two scopes with different owners, different tokens, and different failure modes. So: two directories, two completely separate states.

DirManagesTokenCadence
enterprise/Enterprise 21Stark: Actions policy, security-analysis defaultsclassic PAT, admin:enterpriserare
org/Org 21StarkCom: settings singleton, Actions policy, membersclassic PAT, admin:orgthe bulk

Both states live in the same GCS bucket under different prefixes (github/21stark/enterprise and github/21stark/org). The ADR rejected a single flat state in one sentence: "an enterprise apply could then break org membership." State isolation is blast-radius isolation. An apply in one tier physically can't touch the other tier's resources. Policies cascade downward, so the enterprise tier holds the defaults that must never regress and the org tier holds the day-to-day.

Two more deliberate choices:

  • Token asymmetry is real, not theoretical. My default gh keyring token is read:org only. It can plan-read basic org settings but can't read Actions policy or apply anything. The org tier needs gh auth refresh -s admin:org. The enterprise tier needs a separate classic PAT living in the macOS Keychain. Escalation is a conscious act, per tier.
  • Local-apply only, no CI. Governance changes are rare and sensitive, so no CI apply until it earns it. I solved the self-referential WIF deadlock twice during the migration (the CI that applies Terraform authenticates through the very binding the apply rewrites). I'm in no hurry to build a third instance of that puzzle for the repo that governs everything else.

The provider is integrations/github, constrained ~> 6.0 and locked at v6.13.0 in both tiers' committed lock files.

Import-first: the org already exists, respect that

The org was live: members, settings, 44 repos, real CI. The one thing a first apply must never do is create a member, team, or org that's already there. Terraform is brilliant at converging on desired state, and that's exactly the danger. If your code disagrees with reality, Terraform will happily "fix" reality.

The workflow, per resource:

  1. Write the resource block, mirroring the live values exactly.
  2. Write a matching import {} block next to it.
  3. terraform plan must show import, not create. Anything else means your mirror is wrong. Fix the code, not the org.
  4. Apply. Delete the import block once absorbed.

This is org/members.tf, lightly abridged. It's the complete membership model:

# org/members.tf
resource "github_membership" "member" {
  for_each = var.owners
  username = each.key
  role     = each.value
}

# Import existing memberships (id = "org:username").
import {
  to = github_membership.member["aryeh-stark"]
  id = "21StarkCom:aryeh-stark"
}

One member. Managing a single membership with for_each looks like ceremony. It isn't. The shape is the contract: the next member is a one-line diff in a PR, not a click.

The settings singleton is where the discipline gets explicit. This comment sits at the top of org/settings.tf, and it's the most important paragraph in the repo:

# org/settings.tf
# github_organization_settings is a SINGLETON. Every field is authoritative
# once applied. These values MIRROR THE LIVE ORG (fetched 2026-07-11) so the
# first apply after import is a strict no-op. Change nothing here except via
# a deliberate PR. A flipped bool silently rewrites org-wide policy.

The acceptance test for the whole scaffold is one line of commit history: "both tiers plan clean (import-only, 0 change)."

The provider ceiling: document what you can't manage

An honest IaC repo states what it doesn't control. The worst response to a provider ceiling is scaffolding fake resources that imply coverage you don't have.

  • App installations. Terraform can't install a GitHub App; the provider only manages which repos an already-installed app reaches. So org/apps.tf is 22 lines of pure documentation: the three least-privilege automation apps (Stark Claude 21S, Stark Codex 21S, Stark Gemini 21S) with their installation IDs recorded (141330560, 141330526, 141330618), the install click-op described, and repo-pinning left as a commented example. Zero resources. On purpose.
  • The enterprise tier is thin. SAML, SCIM, billing and seats, spending limits, enterprise rulesets, the org-creation flow: none of it is Terraform-able. All of it is written up as click-ops in enterprise/README.md, and enterprise.tf ends with an explicit "What the provider CANNOT manage" block that says do NOT scaffold resources for these.
  • Teams: none. org/teams.tf is a commented scaffold. The fleet is currently flat under a single owner, and an empty file that says why it's empty beats a fake team hierarchy.
  • Enterprise metadata is GraphQL-only. The REST route for the enterprise 404s. The provider's data source resolves it via GraphQL, which is why the enterprise token scope matters. Recorded as a comment where the data source lives, so the next 404 costs nobody an afternoon.

Every one of these is a documented click-op: a manual action named, located, and justified inside the repo instead of being silently absent from it. When future me asks "is X managed?", the answer is in the code either way.

The hardening morning, including a $30 near-miss

Scaffolding was commits one through three. PRs #1 through #4 were the hardening, same morning:

  1. Dependabot plus dependency graph (org tier), ON for new repos. Free on private repos, zero breakage. The cheapest posture win that exists.
  2. Secret scanning plus push protection (enterprise tier, cascades to the org), ON for new repos. Given the tfplan leak, this wasn't theoretical.
  3. Backfill to the existing fleet. Defaults only cover new repos, so a 41-line idempotent script (scripts/backfill-repo-security.sh, paginated, skips archived) swept the fleet: 32 active repos enabled, 12 archived skipped, 0 failures, 0 secret alerts surfaced.
  4. The cost fix, which is the reason to review governance like code.

GitHub splits its security products into two separately billed items: Secret Protection at $19 per committer per month, and Code Security at $30 per committer per month. I use the first (1 of 1 seats, solo committer, no overage). I don't use the second (0 seats). The imported enterprise defaults, left alone, would have auto-enabled Code Security on the next repo I created. Not a breach, not an outage. Just a quiet $30/month subscription initiated by a default nobody chose.

PR #4 killed it at the org tier (advanced_security_enabled_for_new_repositories = false). The enterprise side of that fix carries my favorite comment in the repo:

# enterprise/enterprise.tf
# Code Security ($30): this enterprise flag reflects PRODUCT AVAILABILITY and
# is NOT toggleable off via the API (it reads back true regardless; a false
# here is a perpetual diff). Pinned true to match reality. The COST-CONTROLLING
# switch is the ORG-tier default (advanced_security_enabled_for_new_repositories
# = false in org/settings.tf). That stops new repos from auto-enabling Code
# Security, so committers used = 0 = $0. To truly drop the $30 product,
# cancel it in enterprise billing, not here.
advanced_security_enabled_for_new_repositories = true

The flag is pinned true not because I want the product, but because the API refuses to report anything else, and a false in code would be a diff forever. The actual control lives one tier down. This is the kind of knowledge that evaporates when governance lives in a settings page.

The rest of the API gotchas from that morning, recorded in comments where they were hit:

  • Enabling advanced_security per-repo returns 422 "not available, nor a pre-requisite" under the split billing model. Secret scanning enables directly without it.
  • secret_scanning_validity_checks_enabled doesn't persist at enterprise scope. The API reads it back false no matter what you send. Pinned false to keep the plan clean.
  • 2FA enforcement is read-only in the provider. Not managed, and the file says so (low value at one human member).

Each of these is an hour of debugging that happened exactly once and will never happen again, because the conclusion is committed next to the resource it applies to.

Deferred, loudly

One thing didn't ship: the Actions allowlist. The fleet's CI reaches production GCP through WIF, and Actions policy is still permissive. That's the biggest remaining hole, and the ADR doesn't hide it: "The WIF→GCP supply-chain blast radius is NOT yet tightened (Actions still all/all). Tracked as the deferred follow-up."

Two concrete blockers:

  • No action inventory yet. The provider can set the allowlist today: the allowed_actions_config block (github_owned_allowed, patterns_allowed, verified_allowed) on the same resource already sitting in org/actions.tf, under allowed_actions = "selected". What's missing is the input. Building patterns_allowed means inventorying every action the fleet's workflows actually use. Guess and you break CI; wildcard and you've hardened nothing.
  • sha_pinning_required = true is a migration, not a flip. Org-wide, it breaks every workflow using tag refs (@v4) across the whole fleet until each one is rewritten to a SHA.

A named open risk beats a hidden one every time. The same ADR carries a "What this is not" section: not a retroactive scan, not 2FA enforcement, not an Actions lockdown. Scope stated in the negative is scope you can hold someone to.

The zero-diff invariant

Risograph-style print of a brass balance scale perfectly level, one pan holding a small office building and the other a rolled paper scroll, with the pointer needle at dead center

When adopting infrastructure-as-code over a live system, every step must pass through a state where terraform plan reports zero changes.

That's the single transferable idea here, and it forces three things. Imports show import, never create. The first apply after import is a strict no-op, which proves the mirror is faithful before you're allowed to change anything. Perpetual diffs are bugs in your model of reality: when the API refuses a value, you pin the code to what the API actually reports and document why, because a plan that's never clean trains everyone to ignore the plan, and then the one diff that matters scrolls past unread.

Adoption and modification never share a commit. First you converge on zero, then you diff on purpose.

The invariant isn't GitHub-specific. It's the only sane way to put anything declarative over a brownfield system: a DNS zone, a Kubernetes cluster someone built by hand, an IAM policy archaeology site. The plan output is your only trustworthy instrument, and it's only trustworthy if zero means zero.

Steal this

  • Start with governance, not repos. Settings singleton, Actions policy, membership, security defaults. Highest blast radius per line of HCL by a wide margin. Repos can come later or never.
  • Two tiers, two states. Match state boundaries to token boundaries, so an apply physically can't exceed the credential that runs it.
  • Hold the zero-diff invariant. Mirror live, import, prove the no-op, then and only then change things.
  • Document the ceiling. Empty scaffolds with honest comments, click-ops written down next to the resources they neighbor. Coverage lies are worse than coverage gaps.
  • Hunt the billing defaults. My review caught one $30/month auto-subscribe. Yours has one too. Per-committer products with enabled-for-new-repos defaults are the shape of cost that never shows up in any review, because no review exists.
  • Defer loudly. An ADR that names the open risk is governance. A settings page that hides it is a liability with a UI.

The part that scales

The repo's CLAUDE.md names the next tenant: an evinced/ directory for the enterprise I run platform for at work, same layout, same invariant. That's the real payoff of the 47 minutes. The first org cost a morning because the thinking happened there. The second one inherits the thinking and costs an afternoon of imports.

Your org's governance is already a Terraform plan. It's stored in your head, unreviewed, unversioned, with apply access granted to anyone who can find the settings page. Write it down and make the machine hold the diff.

I spent two weeks rewriting authorship across ten migration waves and never once opened the page that governed all of them. 273 lines, and most of the value is in the comments. None of that knowledge existed anywhere before, except as scar tissue in my head.

Get in touch

I write about AI-first engineering on LinkedIn. Specs in, production out, nobody types code. Follow along there, or send a note.

hi@21stark.com · LinkedIn opens my profile, message me from there


Or send it from here

Providing your name, email address, and message is voluntary; without them you cannot use this form. Aryeh Kiovetsky, operating as 21Stark, controls this information. We use it to receive and answer your message, and provide it to Google Cloud for hosting and storage.