From cd861ef08aae780aea53dbffcd44a7304fa158f6 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Sat, 30 May 2026 22:23:40 -0400 Subject: [PATCH] feat(rulesets): reverse-engineer org branch protection + add review gate, conventional commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codifies the pre-Terraform live state of the org-level rulesets and applies the directives from #2/#3 review. Single PR because the imports must land together with the new resources to avoid duplicate ruleset creation on next apply. New rulesets ------------ - `github_organization_ruleset.org_branch_protection` — non-bypassable quality gate on every default branch. Reverse-engineered from the pre-Terraform "main" org ruleset (id 15555419) and extended with strict Conventional Commits enforcement. Rules: - required_linear_history = true - required_signatures = true - branch_name_pattern = starts_with (main|develop|feat|fix|hotfix|release|chore) - commit_message_pattern = regex enforcing Conventional Commits v1.0.0 - pull_request = thread resolution required, 0 approvers (the approver requirement lives in org_review_gate with admin bypass) No bypass_actors: signed commits, linear history, and Conventional Commits apply to every actor including OrganizationAdmins. - `github_organization_ruleset.org_review_gate` — separate ruleset so the admin bypass below doesn't accidentally weaken the quality gates above. Rules: - pull_request { required_approving_review_count = 1, require_code_owner_review = true, required_review_thread_resolution = true, allowed_merge_methods = [squash, rebase] } bypass_actors: OrganizationAdmin (actor_id=1, the API constant) in pull_request mode. Any OrganizationAdmin merges their own PRs without external review; non-admin actors must obtain the review. GitHub rulesets do not support per-author conditional rules. The closest approximation — "everyone but an admin needs a reviewer" — is implemented via OrganizationAdmin bypass. Granting a new account the OrganizationAdmin role extends this bypass to them. Existing ruleset reconciled --------------------------- - `github_organization_ruleset.markdown_lint` updated to match live state for clean import: ref_name = ~ALL (was ~DEFAULT_BRANCH), do_not_enforce_on_create = true. Default enforcement stays "evaluate" (legacy default); pass `-var markdown_lint_enforcement=active` to flip on. Live is currently "disabled" — apply moves it to "evaluate". Import blocks ------------- Declared in rulesets.tf so `tofu apply` adopts pre-Terraform state in the same run that adds the new resources: import { to = github_organization_ruleset.org_branch_protection id = "15555419" } import { to = github_organization_ruleset.markdown_lint id = "17062292" } org_review_gate is new (no existing live ruleset), so no import needed. Config — no magic numbers ------------------------- - config/rulesets-defaults.yml gains a `branch_protection` block with the branch name pattern, the Conventional Commits regex, and the allowed merge methods. Patterns are tunable here without editing .tf. - locals.tf decodes the full config file once and exposes per-section locals (push_protection_defaults, branch_protection_defaults). Conventions — identities out of source tree ------------------------------------------- - AGENTS.md adds a "No identities anywhere except providers.tf owner" rule. No usernames, account logins, or person-tied identifiers in .tf resource bodies, comments, variable descriptions, config/*.yml, or CODEOWNERS-style files committed in this repo. CODEOWNERS for managed repos materializes at apply time from a Terraform variable. - The `.github/CODEOWNERS` file that an earlier commit attempt added is intentionally NOT in this PR — it would have hardcoded an owner login. CODEOWNERS for this repo and all dryvist repos lands in a follow-up PR via `github_repository_file` with the owner identity as a `-var` input. Conventions — cost policy ------------------------- - AGENTS.md adds a "Cost policy" section documenting GitHub's pricing model and the rule "never apply a policy or enable a feature that costs money unless the PR body declares the cost and the operator approves it." Includes a matrix of free / GHAS-gated / metered / subscription features as of 2026-05, with sources, and a per-PR cost-impact checklist for any change that touches Actions, GHAS, Codespaces, Copilot, or org/repo settings affecting those. Variables --------- - New: org_branch_protection_enforcement (default "active") - New: org_review_gate_enforcement (default "active") Apply ----- Requires the ORG_ADMIN token tier (gh-claude-org-admin). Apply will: 1. Adopt rulesets 15555419 and 17062292 into Terraform state 2. Rename the imported rulesets to org-branch-protection / org-markdown-lint 3. Add commit_message_pattern rule to the imported branch-protection ruleset 4. Create new org-review-gate ruleset 5. Move markdown_lint enforcement from disabled -> evaluate Cost impact ----------- Free — all ruleset rules are native GitHub features at zero cost on any plan. The markdown_lint workflow runs on Actions; public repos are free, private repos count against the Team-plan free-tier minutes (3000/month GitHub-hosted, $0.002/min self-hosted as of 2026-03-01). No GHAS feature flipped on by this PR. Verification ------------ - tofu init -backend=false and tofu validate -> green - pre-commit run --all-files -> all hooks pass Assisted-by: Claude --- AGENTS.md | 109 ++++++++++++++++++++++++++++ README.md | 29 ++++++-- config/rulesets-defaults.yml | 25 ++++++- locals.tf | 5 +- rulesets.tf | 133 +++++++++++++++++++++++++++++++++-- variables.tf | 40 +++++++++++ 6 files changed, 326 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d7b19fa..e7087ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,14 @@ tooling, not its Proxmox domain content. org doesn't author (MIT LICENSE body, CODE_OF_CONDUCT, etc.) is fetched from a trustworthy upstream via `data "http"` — never committed as a local template. +- **No identities anywhere except `providers.tf` owner.** No usernames, + account logins, email addresses, or person-tied identifiers in `.tf` + resource bodies, comments, variable descriptions, `config/*.yml`, or + `.github/CODEOWNERS`-style files committed in this repo. Identities + that need to materialize at apply time (e.g. CODEOWNERS for managed + repos) come from a Terraform variable that the operator supplies via + `-var` or `TF_VAR_`, never a default. The repo must clone cleanly into + another org without a single rename. - **No local markdownlint config.** This repo *defines* the org-wide markdownlint ruleset (`github_organization_ruleset.markdown_lint`), whose single source of truth is the workflow + `.markdownlint-cli2.yaml` in @@ -73,3 +81,104 @@ any operator who runs `tofu apply` without overrides). Enforce explicitly: ```bash tofu apply -var markdown_lint_enforcement=active ``` + +## Cost policy + +**Never apply a policy or enable a feature that costs money unless the +PR body declares the cost explicitly and the operator approves it.** +GitHub's pricing model means the cost impact of one Terraform-managed +setting can vary 100x depending on repo visibility. Before adding any +new feature flag to org settings, repo settings, or required workflows, +check this matrix. Sources are dated where they're plan-dependent; +re-verify on every pricing change announcement. + +### Free everywhere (any plan, public or private) + +- Org and repo **rulesets** — branch protection, push protection rules + (`max_file_size`, `file_extension_restriction`, `file_path_restriction`, + `max_file_path_length`), required workflows, commit message pattern, + branch name pattern, required signatures, linear history +- Classic branch protection (legacy) +- **Dependabot** version updates + security updates + dependency graph +- Org-level secrets, variables, custom repository roles +- Default workflow GITHUB_TOKEN permissions configuration +- Issue/PR templates, labels, milestones, repository templates, + community health files inherited from `.github` + +### Free on public repos, **paid** on private repos + +These features require [GitHub Advanced Security](https://docs.github.com/en/billing/concepts/product-billing/github-advanced-security) +(GHAS) on private repos. GHAS on Team plan is a per-active-committer +monthly add-on, billed at **$30/committer/month for Code Security** and +**$19/committer/month for Secret Protection** as of 2026-05. An "active +committer" is anyone whose commit landed on a GHAS-enabled repo in the +last 90 days. + +| Setting | Free on public | Paid on private | +| --- | --- | --- | +| `secret_scanning_enabled_for_new_repositories` | yes | yes — Secret Protection | +| `secret_scanning_push_protection_enabled_for_new_repositories` | yes | yes — Secret Protection | +| Code scanning (CodeQL default setup, custom queries) | yes | yes — Code Security | +| Security overview, risk metrics | yes | yes — Code Security | +| `secret_scanning_validity_checks_enabled` | yes | yes — Secret Protection | + +**Policy**: enable these org defaults only when every current and future +repo will be public. The dryvist org has both visibilities, so org +defaults stay off for the GHAS-gated settings; per-repo opt-in is +allowed for repos where the cost has been approved. + +### Metered (free quota then pay-per-unit on private repos) + +GitHub Actions usage on private repos has a free quota then meters by +the minute. Public repos are always free for both GitHub-hosted and +self-hosted runners. Recent change: as of **2026-03-01**, self-hosted +runners on private repos incur a `$0.002/min` GitHub Actions platform +fee — previously zero. See +[Pricing changes for GitHub Actions (2026)](https://resources.github.com/actions/2026-pricing-changes-for-github-actions/). + +| Resource | Free tier (Team plan) | Beyond free (2026-01 pricing) | +| --- | --- | --- | +| Actions minutes, Linux GitHub-hosted, private | 3000 min/month | metered, see GitHub Actions pricing | +| Actions minutes, macOS GitHub-hosted, private | (counts 10x against quota) | $0.048/min | +| Actions minutes, Windows GitHub-hosted, private | (counts 2x against quota) | metered | +| Actions minutes, self-hosted, private | n/a | $0.002/min platform fee (2026-03-01+) | +| Actions storage (artifacts, logs) | 2 GB | $0.25/GB-month | +| Packages storage | 2 GB | $0.25/GB-month | +| Public repos (any runner type) | unlimited | $0.00 | + +**Policy**: do not add a Terraform resource that allocates Actions or +Packages capacity on private repos without first declaring the expected +monthly cost and an approved ceiling in the PR body. For private-repo +CI, prefer self-hosted runners (the per-minute compute is yours, only +the $0.002/min platform fee accrues) over GitHub-hosted runners. + +### Subscription (per-seat or per-org) + +| Product | Cost | +| --- | --- | +| Copilot Business (org) | per-seat monthly | +| Copilot Enterprise | higher per-seat tier | +| Codespaces | per-hour compute + storage when running | +| GHAS Code Security | $30/committer/month (private repos only) | +| GHAS Secret Protection | $19/committer/month (private repos only) | +| Larger / GPU-enabled GitHub-hosted runners | rate per runner size, billed by the minute | + +These are not configured by Terraform here. The mention is so a PR +proposing a `github_*` resource that auto-allocates any of these gets +caught. + +### PR checklist + +Every PR that adds or modifies any of: + +- A `github_organization_settings` attribute +- A `github_actions_organization_*` resource +- A `github_repository` setting that affects visibility, GHAS, or + Actions on private repos +- A required workflow that runs on private repos +- A `github_codespaces_*` or `github_copilot_*` resource + +must include a "**Cost impact**" line in the PR body. State `free` +with the reason (e.g. "applies only to public repos", "native ruleset, +zero per-seat cost") or state the per-unit rate and the expected +monthly burn. PRs lacking this line are not ready to merge. diff --git a/README.md b/README.md index 45f2128..418f026 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,22 @@ defined **once** here and applied to **every** repo automatically. | Resource | Effect | | --- | --- | -| `github_organization_ruleset.org_push_protection` | Native GitHub push rules enforced at the git layer (no workflow runs). Hard ceiling on individual file size and a banned-extension list, applied to every repo, every ref. Thresholds and list live in `config/rulesets-defaults.yml`. | -| `github_organization_ruleset.markdown_lint` | Requires the markdownlint workflow in the org's `.github` repo to pass on the default branch of **every** repo. Single source of truth: the workflow + `.markdownlint-cli2.yaml` both live in `.github`. | +| `github_organization_ruleset.org_push_protection` | Native GitHub push rules at the git layer (no workflow runs). Hard ceiling on individual file size + banned-extension list, applied to every repo, every ref. Thresholds + list live in `config/rulesets-defaults.yml`. | +| `github_organization_ruleset.org_branch_protection` | Quality gate on every default branch: required signatures, linear history, branch name pattern, strict Conventional Commits regex, PR thread resolution. **No bypass** — applies to everyone including org admins. | +| `github_organization_ruleset.org_review_gate` | Review gate on every default branch: 1 approving review + CODEOWNER review on PRs. **OrganizationAdmin bypass in `pull_request` mode** so admins can merge their own PRs; bots and other contributors must obtain the review. | +| `github_organization_ruleset.markdown_lint` | Requires the markdownlint workflow in the org's `.github` repo to pass on every ref of every repo. Single source of truth: the workflow + `.markdownlint-cli2.yaml` both live in `.github`. `do_not_enforce_on_create` so brand-new repos don't fail before their default branch exists. | -Start small — this is the seed. Branch protection, commit-message format, -the verified-signature policy, repo settings, labels, and per-repo file -content (LICENSE, CODEOWNERS) move here next. +Imports needed on first apply (declared in `rulesets.tf` via `import` +blocks, executed automatically by `tofu apply`): + +- `org_branch_protection` ← live ruleset id 15555419 (originally named "main") +- `markdown_lint` ← live ruleset id 17062292 (originally named "Required Workflows - All Branches") + +After successful apply, the `import` blocks can be removed in a follow-up +PR (they're idempotent but only useful once). + +Next up (separate PRs): org Actions permissions, org-level settings, org +variables, per-repo labels and LICENSE files via `for_each`. ## Layout @@ -31,12 +41,19 @@ providers.tf # github provider, GITHUB_TOKEN auth variables.tf # all input variables (no magic numbers in .tf below) data.tf # live lookups: repo IDs, org metadata — never literals locals.tf # config/*.yml decoded into named locals for rulesets.tf -rulesets.tf # org rulesets (markdown_lint, org_push_protection, …) +rulesets.tf # org rulesets + import blocks for pre-Terraform state main.tf # multi-file entrypoint stub (resources organized by topic) outputs.tf # intentionally empty — see file header config/ # YAML thresholds + lists consumed via yamldecode(file(...)) ``` +CODEOWNERS is deliberately NOT committed in this repo. CODEOWNERS files +for every dryvist repo (this one included) are materialized at apply time +by a `github_repository_file` resource in a follow-up PR, with the owner +identity supplied as a Terraform variable. Keeping a static +`.github/CODEOWNERS` here would bake a specific identity into the source +tree, which the no-identity-in-code rule forbids. + `config/` holds plain-data thresholds, extension lists, label sets — read into Terraform via `yamldecode(file(...))` and exposed as locals, never inlined as `.tf` literals. `data.tf` holds live lookups (repo IDs, org diff --git a/config/rulesets-defaults.yml b/config/rulesets-defaults.yml index 008724f..7d3319d 100644 --- a/config/rulesets-defaults.yml +++ b/config/rulesets-defaults.yml @@ -4,9 +4,8 @@ # they're tunable without editing Terraform code, and reviewable in a single # place. -# Consumed by github_organization_ruleset.org_push_protection (added in a -# follow-up commit). Native GitHub push rules — enforced at the git layer -# without any workflow involvement. +# Consumed by github_organization_ruleset.org_push_protection. Native GitHub +# push rules — enforced at the git layer without any workflow involvement. push_protection: # Hard ceiling on individual file size. Any push containing a single file # larger than this is rejected by GitHub before it lands. Repos with a @@ -26,3 +25,23 @@ push_protection: - "*.key" - "*.p12" - "*.pfx" + +# Consumed by github_organization_ruleset.org_branch_protection — the +# non-bypassable quality gate on every repo's default branch. +branch_protection: + # Branch name pattern enforced on every PR. Defined here so the regex + # can change without editing rulesets.tf. + branch_name_operator: starts_with + branch_name_pattern: "(main|develop|feat|fix|hotfix|release|chore)" + + # Strict Conventional Commits regex. Types are the v1.0.0 spec set; + # scope is optional and restricted to lowercase alphanumeric + hyphen; + # `!` indicates a breaking change. Anything else fails. + commit_message_pattern: '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9\-]+\))?(!)?: .+$' + + # Merge methods allowed on PRs. Merge commits explicitly disallowed — + # required_linear_history would reject them anyway, this just removes + # them from the GitHub UI dropdown so contributors can't try. + allowed_merge_methods: + - squash + - rebase diff --git a/locals.tf b/locals.tf index e107f01..682cc0c 100644 --- a/locals.tf +++ b/locals.tf @@ -2,5 +2,8 @@ # file decodes them into named locals so rulesets.tf reads them as terraform # values, not raw file reads scattered through resource bodies. locals { - push_protection_defaults = yamldecode(file("${path.module}/config/rulesets-defaults.yml")).push_protection + rulesets_defaults = yamldecode(file("${path.module}/config/rulesets-defaults.yml")) + + push_protection_defaults = local.rulesets_defaults.push_protection + branch_protection_defaults = local.rulesets_defaults.branch_protection } diff --git a/rulesets.tf b/rulesets.tf index 1192d8e..e534b78 100644 --- a/rulesets.tf +++ b/rulesets.tf @@ -29,12 +29,133 @@ resource "github_organization_ruleset" "org_push_protection" { } } +# Org-wide branch-protection — quality rules on every default branch. +# +# Reverse-engineered from the pre-Terraform "main" org ruleset (id +# 15555419) plus new directives: Conventional Commits enforcement and PR +# thread resolution. No bypass actors: rules apply to everyone including +# org admins, so an admin's own commits are still signed, linear, and +# Conventional-format. Review-count enforcement lives in a separate +# ruleset (org_review_gate) so admin bypass on review doesn't accidentally +# weaken these quality gates. +# +# Import-on-first-apply: the import block below adopts ruleset 15555419 +# into Terraform state so apply reconciles instead of creating a duplicate. +import { + to = github_organization_ruleset.org_branch_protection + id = "15555419" +} + +resource "github_organization_ruleset" "org_branch_protection" { + name = "org-branch-protection" + target = "branch" + enforcement = var.org_branch_protection_enforcement + + conditions { + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + repository_name { + include = ["~ALL"] + exclude = [] + } + } + + rules { + required_linear_history = true + required_signatures = true + + branch_name_pattern { + operator = local.branch_protection_defaults.branch_name_operator + pattern = local.branch_protection_defaults.branch_name_pattern + negate = false + name = "" + } + + commit_message_pattern { + name = "conventional-commits" + operator = "regex" + pattern = local.branch_protection_defaults.commit_message_pattern + negate = false + } + + pull_request { + required_approving_review_count = 0 + dismiss_stale_reviews_on_push = false + require_code_owner_review = false + require_last_push_approval = false + required_review_thread_resolution = true + allowed_merge_methods = local.branch_protection_defaults.allowed_merge_methods + } + } +} + +# Org-wide review gate — 1 approving review + CODEOWNER review on PRs. +# +# Separate ruleset (rather than rolled into org_branch_protection) so the +# OrganizationAdmin bypass below applies ONLY to review enforcement, not +# to signed commits, linear history, or commit format. Any OrganizationAdmin +# can merge their own PRs without external review; non-admin actors (bots, +# external contributors) must obtain the review and, on critical files, +# a CODEOWNER review. +# +# bypass_mode = "pull_request": admins bypass on merge only, not on push. +# Pushes still satisfy every other rule (signed, linear, conventional). +# Granting an additional account the OrganizationAdmin role extends this +# bypass to them — review the role assignments before adding admins. +resource "github_organization_ruleset" "org_review_gate" { + name = "org-review-gate" + target = "branch" + enforcement = var.org_review_gate_enforcement + + conditions { + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + repository_name { + include = ["~ALL"] + exclude = [] + } + } + + rules { + pull_request { + required_approving_review_count = 1 + dismiss_stale_reviews_on_push = false + require_code_owner_review = true + require_last_push_approval = false + required_review_thread_resolution = true + allowed_merge_methods = local.branch_protection_defaults.allowed_merge_methods + } + } + + bypass_actors { + # OrganizationAdmin role: actor_id = 1 is the only valid value for this + # actor_type per the GitHub Rulesets API (a protocol constant, not a + # tunable threshold). Every OrganizationAdmin bypasses review on merge. + actor_id = 1 + actor_type = "OrganizationAdmin" + bypass_mode = "pull_request" + } +} + # Org-wide markdown linting, enforced as a Required Workflow. # -# Every repo's default-branch PRs must pass the markdownlint workflow that -# lives in the org's `.github` repo (resolved at apply time via -# data.github_repository.dot_github). One workflow + one config — no -# per-repo markdownlint files to drift, no per-repo `uses:` wiring. +# Adopts the pre-Terraform "Required Workflows - All Branches" ruleset (id +# 17062292) so apply reconciles instead of creating a duplicate. Live state +# at import time: enforcement=disabled, ref_name=~ALL, do_not_enforce_on_create. +# Terraform code below uses ~ALL refs (matching live) and keeps +# do_not_enforce_on_create so brand-new repos aren't blocked on first push +# before their default branch exists. Enforcement variable defaults to +# `evaluate` (the legacy default) — pass `-var markdown_lint_enforcement=active` +# to flip on. +import { + to = github_organization_ruleset.markdown_lint + id = "17062292" +} + resource "github_organization_ruleset" "markdown_lint" { name = "org-markdown-lint" target = "branch" @@ -42,7 +163,7 @@ resource "github_organization_ruleset" "markdown_lint" { conditions { ref_name { - include = ["~DEFAULT_BRANCH"] + include = ["~ALL"] exclude = [] } repository_name { @@ -53,6 +174,8 @@ resource "github_organization_ruleset" "markdown_lint" { rules { required_workflows { + do_not_enforce_on_create = true + required_workflow { repository_id = data.github_repository.dot_github.repo_id path = ".github/workflows/markdownlint.yml" diff --git a/variables.tf b/variables.tf index 130e0f4..ab6bddc 100644 --- a/variables.tf +++ b/variables.tf @@ -18,6 +18,46 @@ variable "markdown_lint_enforcement" { } } +variable "org_branch_protection_enforcement" { + description = <<-EOT + Enforcement mode for the org-wide branch-protection ruleset on default + branches. Quality rules — required signatures, linear history, branch + name pattern, Conventional Commits commit messages, PR thread + resolution. NO bypass actors: applies to everyone, so admin-authored + commits get the same quality gates as everyone else. + + One of: disabled, evaluate, active. Defaults to "active" — matches the + pre-Terraform live enforcement state of the imported ruleset. + EOT + type = string + default = "active" + + validation { + condition = contains(["disabled", "evaluate", "active"], var.org_branch_protection_enforcement) + error_message = "org_branch_protection_enforcement must be one of: disabled, evaluate, active." + } +} + +variable "org_review_gate_enforcement" { + description = <<-EOT + Enforcement mode for the org-wide review-gate ruleset on default + branches. Requires 1 approving review + CODEOWNER review on PRs. + Bypass actor: OrganizationAdmin in `pull_request` mode — any account + holding the OrganizationAdmin role can merge their own PRs without + external review. Non-admin actors (bots, external contributors) must + obtain the review. + + One of: disabled, evaluate, active. Defaults to "active". + EOT + type = string + default = "active" + + validation { + condition = contains(["disabled", "evaluate", "active"], var.org_review_gate_enforcement) + error_message = "org_review_gate_enforcement must be one of: disabled, evaluate, active." + } +} + variable "org_push_protection_enforcement" { description = <<-EOT Enforcement mode for the org-wide push-protection ruleset (native