Branch Protection and Repository Policy as Code: Terraform GitHub for Hundreds of Repos

Branch Protection and Repository Policy as Code: Terraform GitHub for Hundreds of Repos

Problem

Branch protection — required reviewers, status checks, push restrictions, signed commits — is the gate between “developer pushes code” and “code reaches production.” When configured by hand-clicking through GitHub’s UI, it silently degrades:

  • New repositories created without the org-standard protection.
  • Engineers add temporary exceptions (“just disable required reviews to merge this hotfix”) that never get reverted.
  • Policy changes (new required check, updated reviewer count) require visiting every repo individually — at scale, this never finishes.
  • No audit trail of when a rule changed, by whom, why.
  • No diff workflow for policy changes; admins make decisions in isolation.

By 2026 the practical pattern is repository policy as code: Terraform’s github provider, modeled per-repo, applied centrally. New rules ship via PR and rollout; rule history is git history; manual UI changes are reverted by the next plan apply.

The specific gaps in a default GitHub org configuration:

  • Org-wide rulesets exist (since 2023) but interact awkwardly with repo-level settings.
  • Branch-protection rules per repo are not idempotent through clicking.
  • Required reviewers don’t propagate to forks unless explicitly configured.
  • Status-check requirements drift as CI workflows are renamed.
  • CODEOWNERS files exist but aren’t enforced for older repos.
  • Repository visibility, secret-scanning, dependabot, and signed-commit settings are scattered across the UI.

This article covers the Terraform GitHub provider for branch protection rulesets, a shared module pattern for policy uniformity, the migration from clickops to code, audit-log integration, and the rollout patterns for policy changes that affect every repo.

Target systems: Terraform 1.10+ with the integrations/github provider 6.4+; GitHub.com Cloud or GitHub Enterprise Cloud / Server; OpenTofu also supported.

Threat Model

  • Adversary 1 — Insider with admin on a single repo: disables branch protection on a repo to push directly to main, bypassing review.
  • Adversary 2 — Compromised maintainer account: uses admin access to weaken protection (lower reviewer count, remove required CI check) and merge malicious code.
  • Adversary 3 — Drift via creation race: new repo created and seeded with code before any branch protection is applied.
  • Adversary 4 — Policy regression on rule change: an org-wide policy update is rolled out incorrectly, weakening protection on many repos at once.
  • Access level: Adversary 1 has repo admin. Adversary 2 has maintainer / admin via stolen credentials. Adversary 3 has repo creation rights. Adversary 4 has org admin.
  • Objective: Bypass code review, push unauthorized changes, weaken supply-chain controls.
  • Blast radius: Without policy-as-code, a single weakening goes undetected indefinitely. With policy-as-code, every change is a PR; weakenings are immediately visible in diff and require explicit approval.

Configuration

Step 1: Terraform Provider Setup

# providers.tf
terraform {
  required_version = ">= 1.10"
  required_providers {
    github = {
      source  = "integrations/github"
      version = "~> 6.4"
    }
  }
  backend "s3" {
    bucket = "myorg-terraform-state"
    key    = "github-policy/terraform.tfstate"
    region = "us-east-1"
    encrypt = true
    dynamodb_table = "terraform-state-lock"
  }
}

provider "github" {
  owner = "myorg"
  # Auth via GITHUB_TOKEN env var with admin:org scope; ideally a GitHub App.
}

For larger orgs, use a GitHub App rather than a PAT. App-based auth allows fine-grained permissions (Contents: read, Administration: write) and eliminates expiring PATs.

Step 2: Shared Module for Repository Policy

Define one module that captures the org-standard policy. Each repo instantiates it.

# modules/standard-repo/main.tf
variable "name" { type = string }
variable "description" { type = string }
variable "visibility" { type = string; default = "private" }
variable "required_reviewers" { type = number; default = 2 }
variable "required_checks" { type = list(string); default = [] }
variable "allow_dangerous" { type = bool; default = false }

resource "github_repository" "this" {
  name        = var.name
  description = var.description
  visibility  = var.visibility

  has_issues   = true
  has_projects = false
  has_wiki     = false

  delete_branch_on_merge      = true
  allow_merge_commit          = false
  allow_squash_merge          = true
  allow_rebase_merge          = false
  allow_auto_merge            = true

  vulnerability_alerts        = true

  security_and_analysis {
    secret_scanning {
      status = "enabled"
    }
    secret_scanning_push_protection {
      status = "enabled"
    }
    advanced_security {
      status = "enabled"
    }
  }
}

resource "github_repository_ruleset" "main_branch" {
  name        = "main-branch-protection"
  repository  = github_repository.this.name
  target      = "branch"
  enforcement = "active"

  conditions {
    ref_name {
      include = ["refs/heads/main"]
      exclude = []
    }
  }

  rules {
    creation                = true
    deletion                = true
    non_fast_forward        = true
    required_linear_history = true
    required_signatures     = true

    pull_request {
      required_approving_review_count   = var.required_reviewers
      dismiss_stale_reviews_on_push     = true
      require_code_owner_review         = true
      require_last_push_approval        = true
      required_review_thread_resolution = true
    }

    required_status_checks {
      strict_required_status_checks_policy = true
      required_check {
        context = "ci/build"
      }
      dynamic "required_check" {
        for_each = var.required_checks
        content {
          context = required_check.value
        }
      }
    }
  }

  bypass_actors {
    actor_id    = 0
    actor_type  = "OrganizationAdmin"
    bypass_mode = "pull_request"
  }
}

# CODEOWNERS file managed via repository file resource.
resource "github_repository_file" "codeowners" {
  repository = github_repository.this.name
  branch     = "main"
  file       = ".github/CODEOWNERS"
  content    = templatefile("${path.module}/codeowners.tpl", {
    name = var.name
  })
  commit_message      = "chore: enforce CODEOWNERS"
  overwrite_on_create = true
}

Step 3: Per-Repo Configuration

Each repo’s policy is a small block calling the module:

# repos/payments-api.tf
module "payments_api" {
  source = "../modules/standard-repo"
  name        = "payments-api"
  description = "Payments service API"
  required_reviewers = 2
  required_checks = ["security/cosign-verify", "security/trivy-scan"]
}

# repos/internal-tools.tf
module "internal_tools" {
  source = "../modules/standard-repo"
  name        = "internal-tools"
  description = "Internal tooling and scripts"
  required_reviewers = 1
}

A weekly drift detection (terraform plan against the org) catches manual UI changes.

Step 4: Org-Wide Rulesets for Cross-Cutting Rules

Some rules apply to every repo by default; manage them at the org level.

resource "github_organization_ruleset" "all_repos_main" {
  name        = "all-repos-main-baseline"
  target      = "branch"
  enforcement = "active"

  conditions {
    ref_name {
      include = ["refs/heads/main"]
      exclude = []
    }
    repository_name {
      include = ["~ALL"]
      exclude = ["myorg/sandbox-*"]   # sandboxes exempt
    }
  }

  rules {
    deletion         = true   # block branch deletion on main
    non_fast_forward = true   # block force-push on main
    required_signatures = true
  }
}

Org-level rules apply universally; per-repo rules add additional constraints. New repos automatically inherit the org baseline even before per-repo Terraform runs.

Step 5: CI Workflow to Apply Plans

Apply changes via PR + automation:

# .github/workflows/terraform.yml in the policy repo.
name: Terraform GitHub Policy
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.10.0
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-state-role
          aws-region: us-east-1
      - name: Plan
        env:
          GITHUB_TOKEN: ${{ secrets.ORG_ADMIN_TOKEN }}
        run: terraform plan -no-color | tee plan.txt
      - name: Comment plan on PR
        if: github.event_name == 'pull_request'
        uses: peter-evans/create-or-update-comment@v4
        with:
          issue-number: ${{ github.event.pull_request.number }}
          body-file: plan.txt
      - name: Apply
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        env:
          GITHUB_TOKEN: ${{ secrets.ORG_ADMIN_TOKEN }}
        run: terraform apply -auto-approve

Every change is a PR with a plan visible to reviewers. Apply only on merge to main. Manual changes via the UI are reverted on the next scheduled plan.

Step 6: Drift Detection

Schedule a weekly drift check that fails loudly if anyone has clicked something in the UI:

# .github/workflows/drift-check.yml
name: Drift detection
on:
  schedule:
    - cron: "0 6 * * 1"   # Monday 06:00 UTC

jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: |
          terraform init
          terraform plan -detailed-exitcode -out=plan.bin
        env:
          GITHUB_TOKEN: ${{ secrets.ORG_ADMIN_TOKEN }}
      - if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text": "Repo policy drift detected. Review terraform plan in CI logs."}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DRIFT_WEBHOOK }}

-detailed-exitcode returns 2 when the plan would change resources — drift exists. Slack-alert and review.

Step 7: Policy Version Pinning Per-Repo

For phased rollouts, version the shared module and let repos opt in to the latest policy:

module "payments_api" {
  source  = "git::https://github.com/myorg/policy-modules.git//standard-repo?ref=v2.0.0"
  name    = "payments-api"
  ...
}

A new policy version goes through testing in a few canary repos before propagation across the fleet. The ?ref= pin prevents accidental policy changes.

Expected Behaviour

Signal Click-ops Policy-as-code
New repo born without protection Common Impossible if creation is via the same Terraform
Rule rollback Manual; lossy git revert; full audit
Drift between repos Frequent Detected weekly
Audit trail GitHub audit log only Git history of policy + GitHub audit log of applied changes
Time to apply org-wide rule change Hours-to-days; never finishes Hours; automated
Dispute about who changed a rule Hard to investigate PR shows author and reviewer

Trade-offs

Aspect Benefit Cost Mitigation
Terraform GitHub provider Standard tooling; predictable Provider has quirks (some resources don’t import cleanly) Use the latest provider; for unsupported settings, fall back to GitHub API via restapi resource.
Shared module Uniformity Less per-repo flexibility Variables expose the dimensions worth varying (reviewer count, required checks); resist module sprawl.
Org-wide rulesets Universal baseline Tougher to exempt edge cases Use ruleset conditions to exclude specific repos; document the exemptions.
GitHub App auth Fine-grained permissions, no expiring PAT Setup overhead; rotation policy needed Use Atlantis or Spacelift to manage app credentials.
Drift detection Catches click-ops drift False positives on emergency in-UI changes Document the emergency-bypass procedure: in-UI change followed by Terraform sync within 24 hours.
Policy version pinning Phased rollout Repos may stay on stale versions if forgotten Periodic upgrade-PR automation; deprecate old versions.

Failure Modes

Failure Symptom Detection Recovery
GitHub Actions secret leaked Anyone who can read the workflow can apply terraform GitHub audit log shows actions from unexpected source Rotate token; investigate. Use OIDC federation rather than long-lived secrets where possible.
Provider rate-limited Terraform plan / apply slow or fails Provider logs 429 rate-limited Use parallelism setting to lower API call rate; spread plan over multiple repos rather than a single global plan.
Rule change weakens protection accidentally Reviewer count lowered; required check removed Drift check flags the change after merge PR review should catch; set required-reviewers on the policy repo itself to >2. The policy-of-policy is critical.
Repository created outside Terraform Repo lacks protection; not in state Drift check detects un-managed repo Periodic enumeration: query GitHub API for repos, diff against Terraform state, alert on missing.
CODEOWNERS file deleted by user Required reviewers no longer enforced drift check shows file content drift Terraform reasserts the file content on next apply; investigate why it was deleted.
Org policy bypass via fork Forks of internal repos may not inherit protection Contributor uses fork to bypass review Disable forking on sensitive repos via fork_pull_request_workflows: false ruleset configuration.
Apply runs in the wrong direction Test-policy applied to production Production rules suddenly relaxed Use Terraform workspaces or separate state files per environment; never share state between policy environments.

When to Consider a Managed Alternative

Self-hosted policy-as-code requires Terraform infrastructure, GitHub App management, drift detection, and ongoing module maintenance (4-8 hours/month for a 100-repo org).

  • GitHub Advanced Security: built-in policy enforcement features for orgs with the licence.
  • Atlantis: PR-driven Terraform with merge-on-apply; reduces the apply pipeline to a config file.
  • Spacelift: managed Terraform-as-a-service; integrates with GitHub.
  • Terraform Cloud: HashiCorp-managed state and runs.