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
109 changes: 109 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
25 changes: 22 additions & 3 deletions config/rulesets-defaults.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 4 additions & 1 deletion locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
133 changes: 128 additions & 5 deletions rulesets.tf
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,141 @@ 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"
enforcement = var.markdown_lint_enforcement

conditions {
ref_name {
include = ["~DEFAULT_BRANCH"]
include = ["~ALL"]
exclude = []
}
repository_name {
Expand All @@ -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"
Expand Down
Loading
Loading