$ cd ../writing

githubterraformgovernance· 2025-09-05· 6 min

Automating GitHub repository management with Terraform

One JSON file lists the repositories. Terraform decides their settings, team permissions, and branch protection. Plus the honest part: what the provider cannot do and how we work around it.

Repository settings are the most boring kind of important.

Nobody wants to own them. They live behind six clicks in a settings page, they are invisible until they matter, and they matter exactly once: the day somebody force-pushes to main, or merges without review, or leaves a repo world-writable because the default was fine at creation and nobody looked since.

Multiply by fifty repositories and you have a governance problem disguised as a preferences screen.

The companion piece covers team membership syncing from Azure AD. This one covers what those teams are allowed to do, and how every repository ends up with the same guardrails without anyone configuring them one at a time.

The registration file is the whole interface

Onboarding a repository is one entry in one JSON file:

{
  "repositories": [
    {
      "name": "github-automation",
      "topics": ["github", "devops", "automation", "HCL"]
    }
  ]
}

That is the entire user-facing surface. Everything else, merge strategy, team permissions, branch protection, required reviews, is derived. A new repository does not get a decision, it gets a default, and the default is the same one every other repository got.

This matters more than it sounds. The failure mode of per-repository configuration is not that somebody configures it wrong. It is that fifty repositories configured individually by twelve people over three years have fifty slightly different answers, and nobody can tell you which ones are deliberate.

Intersect, do not assume

The configured list is intersected with what actually exists in the org:

data "github_repositories" "all" {
  query           = "org:${var.github_organization}"
  include_repo_id = true
}

locals {
  repositories = {
    for repo in var.repositories : repo.name => repo
    if contains(data.github_repositories.all.names, repo.name)
  }
}

A typo in the JSON, or a repository that was renamed or archived, produces a no-op rather than a failed apply. The config is allowed to be slightly ahead of or behind reality without the nightly run going red and training everyone to ignore it.

A run that fails for boring reasons is a run nobody reads.

Settings, with one deliberate safety catch

resource "github_repository" "repo_settings" {
  for_each = { for repo in local.repositories : repo.name => repo }

  name   = each.key
  topics = each.value.topics

  allow_merge_commit = false
  allow_squash_merge = true
  allow_rebase_merge = true

  squash_merge_commit_title   = "PR_TITLE"
  squash_merge_commit_message = "PR_BODY"

  allow_update_branch    = true
  delete_branch_on_merge = true

  lifecycle {
    prevent_destroy = true
  }
}

Two things worth pointing at.

Merge commits are off. Squash and rebase only, with the squash commit taking the PR title and body. That keeps main linear and makes the history readable as a list of changes rather than a braid of merge bubbles. It is an opinion, applied uniformly, which is the point.

prevent_destroy is not optional. This resource manages repositories that already exist and contain everyone’s work. A refactor that accidentally drops an entry from the map should fail loudly, not queue a repository deletion. When Terraform manages something it did not create and cannot recreate, that lifecycle block is the difference between an incident and an error message.

Permissions come from the team sync

Teams are managed by the other configuration, so this one looks them up rather than creating them:

locals {
  unique_team_names = toset([for r in local.repositories : r.team_name])
  team_slugs = { for name in local.unique_team_names : name => replace(lower(name), " ", "-") }
}

data "github_team" "teams" {
  for_each = local.team_slugs
  slug     = each.value
}

Note the slug derivation. GitHub team slugs are lowercase and hyphenated, while Azure AD display names are neither, so the mapping is a lowercase-and-replace rather than a second field somebody has to keep in sync. Deriving beats declaring whenever the derivation is total.

The permission model itself is role-based, not per-person, and differs by repository type. For a normal service repository: parent teams get read, the development and QA teams get write, service owners get triage, and the DevOps team gets admin. For API-specification repositories the shape changes, because the people who should be reviewing an OpenAPI contract are business analysts and service owners, who get triage rather than write.

The useful property is not the specific matrix. It is that the matrix is written down once, in code, and applies to every repository of that type automatically. When it changes, it changes in one place, in a pull request, with a diff, and the next run applies it to all fifty.

Branch protection as two rulesets, not one

Every repository gets two rulesets rather than one, because they enforce different things at different moments.

The main branch ruleset is about merge quality:

rules {
  required_linear_history = true
  non_fast_forward        = true

  pull_request {
    require_code_owner_review         = true
    dismiss_stale_reviews_on_push     = true
    require_last_push_approval        = true
    required_approving_review_count   = 1
    required_review_thread_resolution = true
  }
}

require_last_push_approval and dismiss_stale_reviews_on_push are the two that people skip, and they are the two that close the “approved, then quietly changed” hole. required_review_thread_resolution means unresolved comments actually block the merge, which is the difference between review as a gate and review as a suggestion.

The feature and fix branch ruleset is about traceability: branch names and commit messages have to carry a ticket reference. That sounds bureaucratic until the day you are bisecting a regression and every commit message points at the context that explains it.

Splitting them is deliberate. One combined ruleset would need conditional logic for which rules apply to which refs, and conditional protection rules are how you end up with protection that silently does not apply.

The honest part: the provider cannot do everything

Two settings we wanted are not in the Terraform provider at all: the allowed merge methods for the pull request merge button specifically, and enabling automatic Copilot code review.

So those are set with direct GitHub API calls from the same workflow that runs Terraform. Not elegant. It is the pragmatic answer to “provider coverage lags the platform”, and pretending otherwise would mean either dropping the settings or waiting indefinitely.

Worth being explicit about what this costs: those two settings are not in the plan output. They are applied imperatively, so they are not drift-detected the way everything else is. If somebody changes them in the UI, the next run re-applies them, but no diff ever showed you it happened. That is a real gap in an otherwise declarative system, and it is the first thing I would move into Terraform when the provider catches up.

Terraform for what it covers, API calls for the gap, and a clear note about which is which. That beats a system that claims to be fully declarative but quietly is not.

Running it

The whole thing runs on a schedule, with one optimization that matters: the apply step is skipped when the plan is empty.

Most runs are empty. Skipping them avoids spending API quota to confirm nothing changed, and, more usefully, it means a non-empty apply is a signal. Something drifted or somebody merged a change. That is worth a look, and it is only worth a look if the normal case is silence.

Manual triggering exists for when you want a change now rather than tonight.

What this actually bought

  • Fifty-plus repositories with identical, deliberate settings, and no drift between them.
  • Onboarding a repository is a one-entry pull request, reviewed like any other change.
  • Governance questions have an answer in git rather than in fifty settings pages.
  • Changing a policy org-wide is one diff.

And the property that took the longest to appreciate: because the configuration is boring and uniform, nobody argues about it. There is no per-repository negotiation, because there is no per-repository choice. Convention removed the conversation, which was always the expensive part.

Where it does not reach

Repository creation is not here. This configures repositories that exist. Creation stayed manual on purpose at this stage, because creating a repository is rare and deliberate, while configuring one should be automatic and invisible.

And as noted, two settings live outside the declarative model. Knowing exactly which two is the whole point of writing it down.


Originally published on LinkedIn, September 2025. This blog is now the canonical version.