From 73349353e4cf148de40e12e2680328beb0aba548 Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sun, 16 Aug 2026 11:07:14 -0600 Subject: [PATCH 1/2] feat: default repositories to squash-only merges Switch the repositories default merge strategy to squash-only (allow_squash_merge = true, allow_merge_commit = false, allow_rebase_merge = false); override per repository via the allow_* fields (at least one must be enabled). Update the default test, docs/DESIGN.md, and regenerate docs. Add a pre-1.0 Stability section to the README documenting the breaking input changes so far. Note: repositories relying on the previous merge-commit default switch to squash-only on next apply. --- README.md | 17 ++++++++++++++++- docs/DESIGN.md | 12 +++++------- modules/repository/README.md | 2 +- modules/repository/tests/repository.tftest.hcl | 9 ++++----- modules/repository/variables.tf | 4 ++-- variables.tf | 4 ++-- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 12cd6eb..13e8ac0 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,21 @@ root). - [`examples/complete`](examples/complete) — manage an entire organization. - [`examples/repository`](examples/repository) — use only the repository submodule. +## Stability + +orgkit is **pre-1.0** and the input interface may still change between minor +releases. Pin a version (`version = "~> 0.2"`) and review the changelog before +upgrading. Breaking input changes so far, since the initial release: + +- **`unsafe_merges` removed** — use `allow_squash_merge` / `allow_rebase_merge` + (and the other granular merge controls) instead. +- **`baseline_ruleset` and `require_signed_commits` removed** — use + `enabled_presets` (defaults reproduce the previous behavior) and + `organization_rulesets`; per-repo rules via `repositories[].rulesets`. +- **Default merge strategy is now squash-only** (`allow_squash_merge = true`, + `allow_merge_commit = false`). Set the `allow_*` fields per repository to + change it. + ## Requirements @@ -113,7 +128,7 @@ root). | [organization\_variables](#input\_organization\_variables) | Org-level shared Actions variables (plaintext value + visibility). |
map(object({
value = string
visibility = optional(string, "private")
repositories = optional(list(string), [])
}))
| `{}` | no | | [owners](#input\_owners) | Explicit set of owner usernames (mapped to GitHub's `admin` role). Ignored when `owners_team` is set. | `set(string)` | `[]` | no | | [owners\_team](#input\_owners\_team) | Convenience: derive organization owners from the members of this team, by
name. `null` (default) means use the explicit `owners` set instead. There is
no hardcoded owners team — the consumer chooses. | `string` | `null` | no | -| [repositories](#input\_repositories) | Repositories and which teams may access them. See the repository submodule for the full object schema and validations. |
list(object({
name = string
description = string
visibility = optional(string, "private")
readers = optional(list(string), [])
writers = optional(list(string), [])
admins = optional(list(string), [])
restricted = optional(bool, false)

topics = optional(list(string), [])
homepage_url = optional(string, null)
has_issues = optional(bool, true)
has_wiki = optional(bool, false)
has_projects = optional(bool, false)
has_downloads = optional(bool, true)
is_template = optional(bool, false)
gitignore_template = optional(string, null)
license_template = optional(string, null)
archived = optional(bool, false)
archive_on_destroy = optional(bool, false)
default_branch = optional(string, null)
enable_pages = optional(bool, false)
signed_commits = optional(bool, true)

allow_merge_commit = optional(bool, true)
allow_squash_merge = optional(bool, false)
allow_rebase_merge = optional(bool, false)
allow_auto_merge = optional(bool, true)
delete_branch_on_merge = optional(bool, true)
merge_commit_message = optional(string, "PR_BODY")
merge_commit_title = optional(string, "PR_TITLE")

template = optional(object({
owner = string
repository = string
include_all_branches = optional(bool, false)
}))

rulesets = optional(map(object({
enforcement = optional(string, "active")
target = optional(string, "branch")
include_refs = optional(list(string), ["~ALL"])
exclude_refs = optional(list(string), [])
bypass_actors = optional(list(object({
actor_type = string
actor_id = optional(number, 0)
team = optional(string)
bypass_mode = optional(string, "always")
})), [])
rules = object({
creation = optional(bool, false)
update = optional(bool, false)
deletion = optional(bool, false)
non_fast_forward = optional(bool, false)
required_signatures = optional(bool, false)
required_linear_history = optional(bool, false)
pull_request = optional(object({
required_approving_review_count = optional(number, 0)
require_code_owner_review = optional(bool, false)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, false)
}))
})
})), {})

variables = optional(map(string), {})
environments = optional(map(object({
variables = optional(map(string), {})
reviewers = optional(list(string), [])
wait_timer = optional(number, 0)
deployment_branch_policy = optional(object({
protected_branches = optional(bool, false)
custom_branch_policies = optional(list(string), [])
}))
})), {})
}))
| `[]` | no | +| [repositories](#input\_repositories) | Repositories and which teams may access them. See the repository submodule for the full object schema and validations. |
list(object({
name = string
description = string
visibility = optional(string, "private")
readers = optional(list(string), [])
writers = optional(list(string), [])
admins = optional(list(string), [])
restricted = optional(bool, false)

topics = optional(list(string), [])
homepage_url = optional(string, null)
has_issues = optional(bool, true)
has_wiki = optional(bool, false)
has_projects = optional(bool, false)
has_downloads = optional(bool, true)
is_template = optional(bool, false)
gitignore_template = optional(string, null)
license_template = optional(string, null)
archived = optional(bool, false)
archive_on_destroy = optional(bool, false)
default_branch = optional(string, null)
enable_pages = optional(bool, false)
signed_commits = optional(bool, true)

allow_merge_commit = optional(bool, false)
allow_squash_merge = optional(bool, true)
allow_rebase_merge = optional(bool, false)
allow_auto_merge = optional(bool, true)
delete_branch_on_merge = optional(bool, true)
merge_commit_message = optional(string, "PR_BODY")
merge_commit_title = optional(string, "PR_TITLE")

template = optional(object({
owner = string
repository = string
include_all_branches = optional(bool, false)
}))

rulesets = optional(map(object({
enforcement = optional(string, "active")
target = optional(string, "branch")
include_refs = optional(list(string), ["~ALL"])
exclude_refs = optional(list(string), [])
bypass_actors = optional(list(object({
actor_type = string
actor_id = optional(number, 0)
team = optional(string)
bypass_mode = optional(string, "always")
})), [])
rules = object({
creation = optional(bool, false)
update = optional(bool, false)
deletion = optional(bool, false)
non_fast_forward = optional(bool, false)
required_signatures = optional(bool, false)
required_linear_history = optional(bool, false)
pull_request = optional(object({
required_approving_review_count = optional(number, 0)
require_code_owner_review = optional(bool, false)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, false)
}))
})
})), {})

variables = optional(map(string), {})
environments = optional(map(object({
variables = optional(map(string), {})
reviewers = optional(list(string), [])
wait_timer = optional(number, 0)
deployment_branch_policy = optional(object({
protected_branches = optional(bool, false)
custom_branch_policies = optional(list(string), [])
}))
})), {})
}))
| `[]` | no | | [secrets](#input\_secrets) | Decrypted secret values keyed by scope. Supplied already-decrypted by the caller; this module never performs decryption. |
object({
org = optional(map(string), {})
repos = optional(map(object({
actions = optional(map(string), {})
environments = optional(map(map(string)), {})
})), {})
})
|
{
"org": {},
"repos": {}
}
| no | | [teams](#input\_teams) | Teams and their membership. A team may hold predefined organization-level
GitHub roles via `org_roles`. Repository access is granted per repository via
readers/writers/admins, not through org roles. |
list(object({
name = string
description = string
members = optional(list(string), [])
org_roles = optional(list(string), [])
}))
| `[]` | no | | [users](#input\_users) | Organization members. `fullname`/`email` are informational; membership is keyed on `username`. |
list(object({
username = string
fullname = optional(string, "")
email = optional(string, "")
}))
| `[]` | no | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e2a3bbc..9aa3493 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -107,11 +107,9 @@ repository module (which would create a dependency cycle). manageable in the provider) and the deprecated `branch_protection_v3` resource (rulesets supersede it). -## Planned default changes +## Merge strategy default -Defaults intended to change in a future major release. They preserve today's -behavior for now, so existing configs see no plan churn until then: - -- **Merge strategy** — switch the default from merge commits to squash-only - (`allow_squash_merge = true`, `allow_merge_commit = false`). The module currently - defaults to merge commits. +Repositories default to **squash-only** merges (`allow_squash_merge = true`, +`allow_merge_commit = false`, `allow_rebase_merge = false`). Override per +repository via the `allow_merge_commit` / `allow_squash_merge` / +`allow_rebase_merge` fields (at least one must be enabled). diff --git a/modules/repository/README.md b/modules/repository/README.md index 237f9d6..99f6261 100644 --- a/modules/repository/README.md +++ b/modules/repository/README.md @@ -47,7 +47,7 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [all\_members\_team\_id](#input\_all\_members\_team\_id) | ID of the all-members team that receives default read access on non-restricted repositories. null disables the default read grant. | `string` | `null` | no | -| [repositories](#input\_repositories) | Repositories to manage and which teams may access them. `readers`, `writers`
and `admins` reference team names from `var.teams`. A team listed at more than
one level gets the highest permission (admin > write > read). Set
`restricted = true` to withhold the default all-members read grant. |
list(object({
name = string
description = string
visibility = optional(string, "private")
readers = optional(list(string), [])
writers = optional(list(string), [])
admins = optional(list(string), [])
restricted = optional(bool, false)

topics = optional(list(string), [])
homepage_url = optional(string, null)
has_issues = optional(bool, true)
has_wiki = optional(bool, false)
has_projects = optional(bool, false)
has_downloads = optional(bool, true)
is_template = optional(bool, false)
gitignore_template = optional(string, null)
license_template = optional(string, null)
archived = optional(bool, false)
archive_on_destroy = optional(bool, false)
default_branch = optional(string, null)
enable_pages = optional(bool, false)
signed_commits = optional(bool, true)

allow_merge_commit = optional(bool, true)
allow_squash_merge = optional(bool, false)
allow_rebase_merge = optional(bool, false)
allow_auto_merge = optional(bool, true)
delete_branch_on_merge = optional(bool, true)
merge_commit_message = optional(string, "PR_BODY")
merge_commit_title = optional(string, "PR_TITLE")

template = optional(object({
owner = string
repository = string
include_all_branches = optional(bool, false)
}))

rulesets = optional(map(object({
enforcement = optional(string, "active")
target = optional(string, "branch")
include_refs = optional(list(string), ["~ALL"])
exclude_refs = optional(list(string), [])
bypass_actors = optional(list(object({
actor_type = string
actor_id = optional(number, 0)
team = optional(string)
bypass_mode = optional(string, "always")
})), [])
rules = object({
creation = optional(bool, false)
update = optional(bool, false)
deletion = optional(bool, false)
non_fast_forward = optional(bool, false)
required_signatures = optional(bool, false)
required_linear_history = optional(bool, false)
pull_request = optional(object({
required_approving_review_count = optional(number, 0)
require_code_owner_review = optional(bool, false)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, false)
}))
})
})), {})

variables = optional(map(string), {})
environments = optional(map(object({
variables = optional(map(string), {})
reviewers = optional(list(string), [])
wait_timer = optional(number, 0)
deployment_branch_policy = optional(object({
protected_branches = optional(bool, false)
custom_branch_policies = optional(list(string), [])
}))
})), {})
}))
| `[]` | no | +| [repositories](#input\_repositories) | Repositories to manage and which teams may access them. `readers`, `writers`
and `admins` reference team names from `var.teams`. A team listed at more than
one level gets the highest permission (admin > write > read). Set
`restricted = true` to withhold the default all-members read grant. |
list(object({
name = string
description = string
visibility = optional(string, "private")
readers = optional(list(string), [])
writers = optional(list(string), [])
admins = optional(list(string), [])
restricted = optional(bool, false)

topics = optional(list(string), [])
homepage_url = optional(string, null)
has_issues = optional(bool, true)
has_wiki = optional(bool, false)
has_projects = optional(bool, false)
has_downloads = optional(bool, true)
is_template = optional(bool, false)
gitignore_template = optional(string, null)
license_template = optional(string, null)
archived = optional(bool, false)
archive_on_destroy = optional(bool, false)
default_branch = optional(string, null)
enable_pages = optional(bool, false)
signed_commits = optional(bool, true)

allow_merge_commit = optional(bool, false)
allow_squash_merge = optional(bool, true)
allow_rebase_merge = optional(bool, false)
allow_auto_merge = optional(bool, true)
delete_branch_on_merge = optional(bool, true)
merge_commit_message = optional(string, "PR_BODY")
merge_commit_title = optional(string, "PR_TITLE")

template = optional(object({
owner = string
repository = string
include_all_branches = optional(bool, false)
}))

rulesets = optional(map(object({
enforcement = optional(string, "active")
target = optional(string, "branch")
include_refs = optional(list(string), ["~ALL"])
exclude_refs = optional(list(string), [])
bypass_actors = optional(list(object({
actor_type = string
actor_id = optional(number, 0)
team = optional(string)
bypass_mode = optional(string, "always")
})), [])
rules = object({
creation = optional(bool, false)
update = optional(bool, false)
deletion = optional(bool, false)
non_fast_forward = optional(bool, false)
required_signatures = optional(bool, false)
required_linear_history = optional(bool, false)
pull_request = optional(object({
required_approving_review_count = optional(number, 0)
require_code_owner_review = optional(bool, false)
require_last_push_approval = optional(bool, false)
dismiss_stale_reviews_on_push = optional(bool, false)
required_review_thread_resolution = optional(bool, false)
}))
})
})), {})

variables = optional(map(string), {})
environments = optional(map(object({
variables = optional(map(string), {})
reviewers = optional(list(string), [])
wait_timer = optional(number, 0)
deployment_branch_policy = optional(object({
protected_branches = optional(bool, false)
custom_branch_policies = optional(list(string), [])
}))
})), {})
}))
| `[]` | no | | [repository\_secrets](#input\_repository\_secrets) | Per-repository Actions and environment secret values (plaintext), keyed by
repository name. Supplied already-decrypted by the caller; this module never
performs decryption. |
map(object({
actions = optional(map(string), {})
environments = optional(map(map(string)), {})
}))
| `{}` | no | | [teams](#input\_teams) | Map of team name => { id, slug } for teams that may be granted repository access or set as environment reviewers. Supplied by the teams module. |
map(object({
id = string
slug = string
}))
| `{}` | no | diff --git a/modules/repository/tests/repository.tftest.hcl b/modules/repository/tests/repository.tftest.hcl index 60f7207..ec1db39 100644 --- a/modules/repository/tests/repository.tftest.hcl +++ b/modules/repository/tests/repository.tftest.hcl @@ -134,7 +134,7 @@ run "rejects_env_secret_for_undeclared_environment" { expect_failures = [github_actions_environment_secret.internal] } -run "defaults_preserve_current_behavior" { +run "default_merge_strategy_is_squash_only" { command = plan variables { @@ -144,15 +144,14 @@ run "defaults_preserve_current_behavior" { assert { condition = alltrue([ github_repository.internal["app"].has_issues, - github_repository.internal["app"].allow_merge_commit, + github_repository.internal["app"].allow_squash_merge, github_repository.internal["app"].allow_auto_merge, github_repository.internal["app"].delete_branch_on_merge, !github_repository.internal["app"].has_wiki, - !github_repository.internal["app"].allow_squash_merge, + !github_repository.internal["app"].allow_merge_commit, !github_repository.internal["app"].allow_rebase_merge, - github_repository.internal["app"].merge_commit_message == "PR_BODY", ]) - error_message = "Repository defaults must reproduce the previous hardcoded behavior." + error_message = "Repositories must default to squash-only merges (squash on; merge-commit and rebase off)." } } diff --git a/modules/repository/variables.tf b/modules/repository/variables.tf index d54f304..7e4bba6 100644 --- a/modules/repository/variables.tf +++ b/modules/repository/variables.tf @@ -29,8 +29,8 @@ variable "repositories" { enable_pages = optional(bool, false) signed_commits = optional(bool, true) - allow_merge_commit = optional(bool, true) - allow_squash_merge = optional(bool, false) + allow_merge_commit = optional(bool, false) + allow_squash_merge = optional(bool, true) allow_rebase_merge = optional(bool, false) allow_auto_merge = optional(bool, true) delete_branch_on_merge = optional(bool, true) diff --git a/variables.tf b/variables.tf index 1a00cf7..3897c7b 100644 --- a/variables.tf +++ b/variables.tf @@ -78,8 +78,8 @@ variable "repositories" { enable_pages = optional(bool, false) signed_commits = optional(bool, true) - allow_merge_commit = optional(bool, true) - allow_squash_merge = optional(bool, false) + allow_merge_commit = optional(bool, false) + allow_squash_merge = optional(bool, true) allow_rebase_merge = optional(bool, false) allow_auto_merge = optional(bool, true) delete_branch_on_merge = optional(bool, true) From 0b2090461d1ae5f1ea5f43b8e2793babb426f8d4 Mon Sep 17 00:00:00 2001 From: Joachim Hill-Grannec Date: Sun, 16 Aug 2026 11:07:14 -0600 Subject: [PATCH 2/2] chore: keep release-please in 0.x for pre-1.0 breaking changes Set bump-minor-pre-major so breaking changes bump the minor version (0.1.0 -> 0.2.0) instead of jumping to 1.0.0 while under 1.0. --- release-please-config.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/release-please-config.json b/release-please-config.json index cef6429..bc7637d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,6 +4,8 @@ "include-v-in-tag": true, "tag-separator": "/", "release-type": "terraform-module", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, "packages": { ".": {} },