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
29 changes: 28 additions & 1 deletion .github/workflows/tf-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,24 @@ jobs:
}

body = body.trim();


// Demoting a non-main default branch deletes its managed github_branch, which GitHub
// rejects at apply ("cannot delete the default branch"). Detect it and link the runbook.
const demotedDefaults = (planSummary.delete || [])
.map(a => (a.match(/module\.repository\[[^\]]*\]\.github_branch\.branch\[[^\]]*\]/) || [])[0])
.filter(Boolean);
if (demotedDefaults.length > 0) {
const docUrl = `${context.serverUrl}/G-Research/github-terraformer/blob/main/docs/switching-default-branch-to-main.md`;
const warning = [
"> [!WARNING]",
"> This plan deletes a managed default branch, which **will fail to apply** — GitHub does not allow deleting a repository's default branch.",
"> If you are switching a repository's default branch back to `main`, follow the required steps first: [switching a default branch back to main](" + docUrl + ").",
">",
"> Affected: " + demotedDefaults.map(a => "`" + a + "`").join(", "),
].join("\n");
body = warning + "\n\n" + body;
}

conclusion = "success";

switch (process.env.PLAN_EXITCODE) {
Expand All @@ -265,6 +282,16 @@ jobs:
}
break;
}

// A default-branch demotion cannot be applied (GitHub blocks deleting a default
// branch). Gate the merge: mark the check action_required until the runbook is followed.
if (demotedDefaults.length > 0) {
const runbook = `${context.serverUrl}/G-Research/github-terraformer/blob/main/docs/switching-default-branch-to-main.md`;
conclusion = "action_required";
title = "Terraform plan: default-branch switch needs manual steps";
summary = `This plan deletes a repository's default branch and cannot be applied as-is. Follow the runbook before merging: ${runbook}`;
text = body;
}
}

const output = { title, summary, text };
Expand Down
36 changes: 36 additions & 0 deletions docs/switching-default-branch-to-main.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
> [!IMPORTANT]
> Switching a repository's default branch from a non-`main` branch (e.g. `master`) **back to `main`** is **not a normal config change**. Editing the YAML alone produces a plan that cannot apply. Follow this runbook.

## Why

When `default_branch` is non-`main`, the module manages that branch as a real `github_branch` resource (injected into `branches_map` in `modules/terraform-github-repository/main.tf`) so it can be created declaratively. Changing `default_branch` back to `main` drops it, so Terraform plans to **destroy `github_branch.branch["master"]`** — but it destroys the branch *before* demoting the default, and GitHub refuses:

```
422 Cannot delete the default branch
```

`master` is never actually deleted, but the apply **fails and keeps failing** until state is reconciled. The fix: **`terraform state rm` the branch first** — this forgets it *without deleting it*, so `master` survives as an ordinary branch and the apply only flips the default.

## Steps

Prereq: an HCP Terraform token for the config repo's workspace (`state rm` makes no GitHub API calls — no App/PEM creds, no `-var-file`).

```bash
git clone https://github.com/G-Research/github-terraformer.git && cd github-terraformer/feature/github-repo-provisioning
ln -sf backend.tf.hcp backend.tf
export TF_CLOUD_ORGANIZATION=<TFC_ORG> TF_WORKSPACE=<WORKSPACE> # config repo's tfc_org input and WORKSPACE variable
terraform init -input=false
terraform state pull > backup.tfstate # always back up first
```

1. **Open a PR** changing `default_branch: master` → `main` in `repos/<repo>.yaml`, and get it approved. Its first plan shows `1 to destroy` (`github_branch.branch["master"]`) — **do not merge**.
2. **Remove the branch from state** (nothing is deleted on GitHub):
```bash
terraform state rm 'module.repository["<repo>"].github_branch.branch["master"]'
```
3. **Re-plan and merge.** The plan is now `0 to destroy` (only `github_branch_default` updates `master` → `main`). Merge; the apply repoints the default and `master` stays put.

> [!CAUTION]
> Between the `state rm` and the merge, state no longer manages `master` but `main` still says `default_branch: master` — any other config PR merging in that window re-creates the branch and collides (`422 Reference already exists`). Approve first, then `state rm`, then merge immediately. Ask for a merge freeze if the org is busy.

**Done:** `default_branch` is `main`, `master` still exists (now unmanaged — re-`import` it if you want it managed again). Roll back via the HCP state history or `terraform import 'module.repository["<repo>"].github_branch.branch["master"]' '<repo>:master'`.
6 changes: 6 additions & 0 deletions feature/github-repo-provisioning/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ import {
id = each.key
}

import {
for_each = { for k, cfg in local.generated_repos : k => cfg if try(cfg.default_branch, "main") != "main" }
to = module.repository[each.key].github_branch.branch[each.value.default_branch]
id = "${each.key}:${each.value.default_branch}"
}

locals {
flattened_generated_branch_protections_v4 = flatten([
for repo, config in local.generated_repos : [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,25 @@ resource "github_repository" "repository" {
# ---------------------------------------------------------------------------------------------------------------------

locals {
branches_map = { for b in var.branches : b.name => b }
# Branches explicitly requested via var.branches.
requested_branches_map = { for b in var.branches : b.name => b }

# On a freshly created repo, auto_init produces a single "main" branch. Pointing the default
# at any other branch (e.g. "master") fails with a 422 because that branch does not exist yet.
# Inject the desired default into the branches to be created (branched off the auto-init "main")
# so github_branch_default can point at it. Skip when the default is "main" (already the
# auto-init branch) or when it is already listed explicitly in var.branches.
default_branch_needs_creation = (
local.auto_init &&
local.default_branch != null &&
local.default_branch != "main" &&
!contains(keys(local.requested_branches_map), local.default_branch)
)

branches_map = merge(
local.requested_branches_map,
local.default_branch_needs_creation ? { (local.default_branch) = { name = local.default_branch } } : {}
)
}

resource "github_branch" "branch" {
Expand All @@ -192,20 +210,7 @@ resource "github_branch_default" "default" {
repository = github_repository.repository.name
branch = local.default_branch

# On a freshly created repo, auto_init produces a single "main" branch. Pointing the
# default at any other branch (e.g. "master") fails with a 422 because that branch does
# not exist yet. Rename the auto-init branch to the desired default in that case. We do
# not rename when the target is "main" (already the auto-init branch) or when it is an
# explicitly managed branch (github_branch.branch), which is created separately above.
rename = local.default_branch != "main" && !contains(keys(local.branches_map), local.default_branch)

depends_on = [github_branch.branch]

# rename only matters at creation time; ignore it afterwards so existing/imported repos
# whose default already matches do not show a perpetual diff.
lifecycle {
ignore_changes = [rename]
}
}

# ---------------------------------------------------------------------------------------------------------------------
Expand Down
Loading