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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@
!/terraform/tests/fixtures/good-archived/
!/terraform/tests/fixtures/good-archived/private/
!/terraform/tests/fixtures/good-archived/private/repo.yml
!/terraform/tests/fixtures/good-advisory-checks/
!/terraform/tests/fixtures/good-advisory-checks/public/
!/terraform/tests/fixtures/good-advisory-checks/public/repo.yml
!/terraform/tests/fixtures/good-empty/
!/terraform/tests/fixtures/good-empty/.gitkeep
!/terraform/tests/fixtures/good-explicit-security-override/
Expand Down
57 changes: 39 additions & 18 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,14 @@
- [10.3.4 `require_last_push_approval`](#1034-require_last_push_approval)
- [10.3.5 `required_approving_review_count`](#1035-required_approving_review_count)
- [10.3.6 `required_review_thread_resolution`](#1036-required_review_thread_resolution)
- [10.4 Ruleset: Release Tag Protection](#104-ruleset-release-tag-protection)
- [10.4.1 `deletion`](#1041-deletion)
- [10.4.2 `non_fast_forward`](#1042-non_fast_forward)
- [10.4.3 `required_signatures`](#1043-required_signatures)
- [10.5 Ruleset: Bypass Actors](#105-ruleset-bypass-actors)
- [10.6 Ruleset: Conditions](#106-ruleset-conditions)
- [10.7 Rules Not Enabled by Default (and Why)](#107-rules-not-enabled-by-default-and-why)
- [10.4 Ruleset: CI Status Advisory](#104-ruleset-ci-status-advisory)
- [10.5 Ruleset: Release Tag Protection](#105-ruleset-release-tag-protection)
- [10.5.1 `deletion`](#1051-deletion)
- [10.5.2 `non_fast_forward`](#1052-non_fast_forward)
- [10.5.3 `required_signatures`](#1053-required_signatures)
- [10.6 Ruleset: Bypass Actors](#106-ruleset-bypass-actors)
- [10.7 Ruleset: Conditions](#107-ruleset-conditions)
- [10.8 Rules Not Enabled by Default (and Why)](#108-rules-not-enabled-by-default-and-why)
- [11. Terraform Resource Lifecycle](#11-terraform-resource-lifecycle)
- [12. Properties Intentionally Omitted](#12-properties-intentionally-omitted)
- [13. Per-Repository Deviation Policy](#13-per-repository-deviation-policy)
Expand Down Expand Up @@ -836,7 +837,27 @@ Requires all review conversation threads to be resolved before merging.

**Recommendation**: `true`. Unresolved review threads indicate open questions or concerns. Merging with unresolved threads means merging code that hasn't been fully vetted. This is a quality gate — every piece of feedback must be explicitly acknowledged (resolved or addressed).

### 10.4 Ruleset: Release Tag Protection
### 10.4 Ruleset: CI Status Advisory

**Name**: `CI Status Advisory`
**Target**: `branch`
**Enforcement**: `evaluate`
**Scope**: `~DEFAULT_BRANCH`
**Bypass**: Repository Admins (`actor_id: 5`, `actor_type: RepositoryRole`, `bypass_mode: always`)
**Materialization**: only when a repo sets top-level `advisory_checks`

This ruleset gives repositories a dry-run required-status-checks tier before those checks become merge-blocking. The rule is omitted entirely when `advisory_checks` is absent or empty, so the baseline remains quiet for repositories that have not opted in.

```yaml
advisory_checks:
- "Terraform Framework Tests"
```

Each string becomes a `required_status_checks.required_check.context` entry with `integration_id = null`, `do_not_enforce_on_create = true`, and `strict_required_status_checks_policy = false`. Because the ruleset enforcement is `evaluate`, GitHub evaluates and reports the would-be requirement without blocking merges.

The active tier remains separate: `required_checks` injects the same kind of contexts into the `Pull Request Gate` ruleset, whose enforcement is `active`. Promotion from burn-in to enforcement is therefore an explicit YAML move from `advisory_checks` to `required_checks`.

### 10.5 Ruleset: Release Tag Protection

**Name**: `Release Tag Protection`
**Target**: `tag`
Expand All @@ -848,7 +869,7 @@ Protects every tag from silent replacement, deletion, or unsigned creation. Publ

Tag creation is intentionally left unrestricted so the normal release workflow works without bypass. The three enabled rules below close the drift vectors that matter.

#### 10.4.1 `deletion`
#### 10.5.1 `deletion`

| | |
|---|---|
Expand All @@ -859,7 +880,7 @@ Prevents deletion of any tag without bypass. Deleting a published release tag br

Admin bypass is deliberate: the owner may need to delete a tag that accidentally embedded a leaked secret, was cut from the wrong SHA, or predates a public release decision.

#### 10.4.2 `non_fast_forward`
#### 10.5.2 `non_fast_forward`

| | |
|---|---|
Expand All @@ -868,7 +889,7 @@ Admin bypass is deliberate: the owner may need to delete a tag that accidentally

Prevents force-updating a tag to point at a different commit. Without this rule, `git tag -f v1.2.3 <new-sha> && git push --force origin v1.2.3` silently re-points the tag, and consumers that re-pull the same tag name receive different bytes than before. This is the classic supply-chain attack vector for tag-based distribution.

#### 10.4.3 `required_signatures`
#### 10.5.3 `required_signatures`

| | |
|---|---|
Expand All @@ -879,9 +900,9 @@ Requires the tag (and the commit it points at) to carry a verified GPG, SSH, or

**Release-please / release-bot note**: release-automation bots must be configured to sign the tags they produce, either via a committed signing key or a GitHub App identity. An unsigned bot-created tag will be rejected by this rule. This is intentional — bot-authored releases carry the same supply-chain weight as human-authored ones.

### 10.5 Ruleset: Bypass Actors
### 10.6 Ruleset: Bypass Actors

The Pull Request Gate and Release Tag Protection rulesets include a bypass actor for Repository Admins:
The baseline rulesets include a bypass actor for Repository Admins:

```yaml
bypass_actors:
Expand All @@ -890,7 +911,7 @@ bypass_actors:
bypass_mode: always
```

**Actor ID `5`** = Repository Admin role. This is essential for a solo developer account without it, no one could merge PRs (since you can't approve your own PR) and no one could perform emergency tag remediation. The bypass is scoped to the Pull Request Gate and Release Tag Protection; the Default Branch Protection ruleset has **no bypass actors**, meaning even admins cannot force-push or delete the default branch.
**Actor ID `5`** = Repository Admin role. This is essential for a solo developer account - without it, no one could merge PRs (since you can't approve your own PR) and no one could perform emergency branch, tag, or status-check remediation. The baseline still governs non-admin contributors normally.

**Available Actor IDs**:

Expand All @@ -907,9 +928,9 @@ bypass_actors:
- `pull_request`: Can only bypass during PR merges
- `exempt`: Exempt from the rule entirely

### 10.6 Ruleset: Conditions
### 10.7 Ruleset: Conditions

The Default Branch Protection and Pull Request Gate rulesets target `~DEFAULT_BRANCH` with no exclusions:
The branch-target baseline rulesets target `~DEFAULT_BRANCH` with no exclusions:

```yaml
conditions:
Expand All @@ -935,11 +956,11 @@ conditions:

`~ALL` on a tag-target ruleset matches every tag. Repositories that want to restrict the ruleset to a subset (e.g., only `v*` tags) should override `rules[*].conditions` in their YAML.

### 10.7 Rules Not Enabled by Default (and Why)
### 10.8 Rules Not Enabled by Default (and Why)

| Rule | Why Not Default | When to Enable |
|------|----------------|----------------|
| `required_status_checks` | No CI/CD workflows defined yet globally | Per-repo when CI pipelines exist |
| Active `required_status_checks` | No CI/CD workflows are globally valid for every repo | Burn in with `advisory_checks`, then promote exact contexts to `required_checks` |
| `required_code_scanning` | Requires CodeQL or other scanning tool setup | When code scanning Actions are configured |
| `required_deployments` | No deployment environments defined | When repos have deployment pipelines |
| `merge_queue` | Overkill for solo developer | When multiple contributors submit concurrent PRs |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/terraform.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions terraform/11-variables.github.tf
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,30 @@ variable "repo_default_rules" {
non_fast_forward = true
required_signatures = true
}
},
{
name = "CI Status Advisory"
target = "branch"
enforcement = "evaluate"
bypass_actors = [
{
actor_id = 5
actor_type = "RepositoryRole"
bypass_mode = "always"
}
]
conditions = {
include = ["~DEFAULT_BRANCH"]
exclude = []
}

rules = {
required_status_checks = {
required_check = []
do_not_enforce_on_create = true
strict_required_status_checks_policy = false
}
}
}
]
}
Expand Down
43 changes: 33 additions & 10 deletions terraform/30-locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ locals {
"vulnerability_alerts", "dependabot_security_updates",
"pages", "security_and_analysis", "template",
"branches", "rules", "actions", "environments",
"codeowners", "required_checks",
"codeowners", "required_checks", "advisory_checks",
])

# Allowed keys for nested object types. These are checked in addition to
Expand Down Expand Up @@ -631,6 +631,10 @@ locals {
# (the default) means no change — CI stays advisory, as before.
required_checks = try(repository.required_checks, [])

# Opt-in advisory status checks. These materialize the CI Status
# Advisory ruleset in evaluate mode without affecting PR mergeability.
advisory_checks = try(repository.advisory_checks, [])

# Effective CODEOWNERS resolution (Finding 1).
#
# Precedence:
Expand Down Expand Up @@ -883,15 +887,19 @@ locals {
}

# Precedence:
# 1. An explicit required_status_checks rule in the repo YAML wins.
# 2. Otherwise, if the repo opted in via top-level `required_checks`
# 1. CI Status Advisory injects top-level `advisory_checks`.
# 2. An explicit required_status_checks rule in the repo YAML wins.
# 3. Otherwise, if the repo opted in via top-level `required_checks`
# AND this ruleset carries the pull_request rule (the PR Gate),
# inject those contexts as a required_status_checks rule.
# 3. Otherwise null (advisory CI, unchanged baseline behavior).
# 4. Otherwise null (advisory CI, unchanged baseline behavior).
# do_not_enforce_on_create=true so branch creation is never blocked
# by a not-yet-reported check.
required_status_checks = (
try(rule.rules.required_status_checks, null) != null ? {
try(rule.rules.required_status_checks, null) != null && (
coalesce(try(rule.name, null), "") != "CI Status Advisory"
|| length(try(rule.rules.required_status_checks.required_check, [])) > 0
) ? {
required_check = [
for required_check in try(rule.rules.required_status_checks.required_check, []) : {
context = required_check.context
Expand All @@ -901,16 +909,27 @@ locals {
strict_required_status_checks_policy = coalesce(try(rule.rules.required_status_checks.strict_required_status_checks_policy, null), false)
do_not_enforce_on_create = coalesce(try(rule.rules.required_status_checks.do_not_enforce_on_create, null), false)
} : (
try(rule.rules.pull_request, null) != null && length(repository.required_checks) > 0 ? {
coalesce(try(rule.name, null), "") == "CI Status Advisory" && length(repository.advisory_checks) > 0 ? {
required_check = [
for context in repository.required_checks : {
for context in repository.advisory_checks : {
context = context
integration_id = null
}
]
strict_required_status_checks_policy = false
do_not_enforce_on_create = true
} : null
strict_required_status_checks_policy = coalesce(try(rule.rules.required_status_checks.strict_required_status_checks_policy, null), false)
do_not_enforce_on_create = coalesce(try(rule.rules.required_status_checks.do_not_enforce_on_create, null), true)
} : (
try(rule.rules.pull_request, null) != null && length(repository.required_checks) > 0 ? {
required_check = [
for context in repository.required_checks : {
context = context
integration_id = null
}
]
strict_required_status_checks_policy = false
do_not_enforce_on_create = true
} : null
)
)
)

Expand Down Expand Up @@ -953,6 +972,10 @@ locals {

# Only generate rulesets if the repository defines them
if length(repository.rules) > 0 && (
coalesce(try(rule.name, null), "") != "CI Status Advisory"
|| length(repository.advisory_checks) > 0
|| length(try(rule.rules.required_status_checks.required_check, [])) > 0
) && (
coalesce(try(rule.target, null), "branch") != "push"
|| (
var.github_supports_push_rulesets
Expand Down
7 changes: 4 additions & 3 deletions terraform/tests/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,11 @@ terraform test
| N11 | `license_template = null` default (Finding 22: MIT removal) | ✅ | `normalization.tftest.hcl::license_template_defaults_null_not_MIT` + default sweep |
| N12 | Branch source ordering (Finding 21) — multi-branch | ✅ | `normalization.tftest.hcl::multi_branch_sources_all_from_default_not_serially` |
| N13 | Fork normalization (source_owner / source_repo) | ✅ | `normalization.tftest.hcl::fork_repo_passes_through_source_fields` |
| N14 | Repo default rulesets applied when no YAML rules | ✅ | `normalization.tftest.hcl::good_minimal_produces_expected_resource_counts` |
| N15 | **All ~28 repo_setting_defaults** (default value sweep) | ✅ | `normalization.tftest.hcl::good_minimal_carries_expected_defaults` |
| N14 | Repo default rulesets applied when no YAML rules; advisory ruleset omitted when unset | ✅ | `normalization.tftest.hcl::good_minimal_produces_expected_resource_counts` |
| N15 | `advisory_checks` materializes the evaluate-mode CI Status Advisory ruleset | ✅ | `normalization.tftest.hcl::good_advisory_checks_materializes_evaluate_ruleset` |
| N16 | **All ~28 repo_setting_defaults** (default value sweep) | ✅ | `normalization.tftest.hcl::good_minimal_carries_expected_defaults` |

**Normalization coverage: 15 / 15 ≈ 100%.**
**Normalization coverage: 16 / 16 ≈ 100%.**

## `for_each` filter regressions

Expand Down
6 changes: 6 additions & 0 deletions terraform/tests/fixtures/good-advisory-checks/public/repo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
advisory-checks-repo:
description: "Public repo with advisory required status checks for terraform test coverage."
visibility: public
advisory_checks:
- "lint / advisory"
- "build / advisory"
82 changes: 79 additions & 3 deletions terraform/tests/normalization.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -259,16 +259,92 @@ run "good_minimal_produces_expected_resource_counts" {
error_message = "good-minimal should produce exactly 1 repository"
}

# good-minimal has no rules, so the default ruleset set (3 rulesets)
# from var.repo_default_rules applies.
# good-minimal has no rules and no advisory_checks, so only the three
# active baseline rulesets materialize from var.repo_default_rules.
assert {
condition = length(output.branch_rulesets) == 3
error_message = "good-minimal should produce the 3 default rulesets (Branch Safety + Pull Request Gate + Release Tag Protection)"
error_message = "good-minimal should produce only the 3 active default rulesets (Branch Safety + Pull Request Gate + Release Tag Protection)"
}

assert {
condition = !contains([for _, ruleset in output.branch_rulesets : ruleset.name], "CI Status Advisory")
error_message = "good-minimal must not materialize the CI Status Advisory ruleset without advisory_checks"
}
}

#endregion --- [ good-minimal resource count smoke ] ----------------------------------------- #

#region ------ [ advisory_checks default ruleset injection ] --------------------------------- #

run "good_advisory_checks_materializes_evaluate_ruleset" {
command = plan

variables {
repo_yaml_path = "tests/fixtures/good-advisory-checks"
}

assert {
condition = length(output.validation_errors) == 0
error_message = "good-advisory-checks fixture produced validation errors: ${join(" | ", output.validation_errors)}"
}

assert {
condition = length(output.branch_rulesets) == 4
error_message = "advisory_checks should materialize the 3 active defaults plus the CI Status Advisory ruleset"
}

assert {
condition = contains(keys(output.branch_rulesets), "advisory-checks-repo-rules-3")
error_message = "CI Status Advisory should keep the default ruleset index 3 when materialized"
}

assert {
condition = (
output.branch_rulesets["advisory-checks-repo-rules-3"].name == "CI Status Advisory"
&& output.branch_rulesets["advisory-checks-repo-rules-3"].target == "branch"
&& output.branch_rulesets["advisory-checks-repo-rules-3"].enforcement == "evaluate"
)
error_message = "CI Status Advisory must be a branch ruleset in evaluate enforcement mode"
}

assert {
condition = (
length(output.branch_rulesets["advisory-checks-repo-rules-3"].include) == 1
&& output.branch_rulesets["advisory-checks-repo-rules-3"].include[0] == "~DEFAULT_BRANCH"
&& length(output.branch_rulesets["advisory-checks-repo-rules-3"].exclude) == 0
)
error_message = "CI Status Advisory must target only the default branch"
}

assert {
condition = (
length(output.branch_rulesets["advisory-checks-repo-rules-3"].bypass_actors) == 1
&& output.branch_rulesets["advisory-checks-repo-rules-3"].bypass_actors[0].actor_id == 5
&& output.branch_rulesets["advisory-checks-repo-rules-3"].bypass_actors[0].actor_type == "RepositoryRole"
&& output.branch_rulesets["advisory-checks-repo-rules-3"].bypass_actors[0].bypass_mode == "always"
)
error_message = "CI Status Advisory must carry the same Repository Admin bypass actor as the active baselines"
}

assert {
condition = (
output.branch_rulesets["advisory-checks-repo-rules-3"].rules.required_status_checks.do_not_enforce_on_create == true
&& output.branch_rulesets["advisory-checks-repo-rules-3"].rules.required_status_checks.strict_required_status_checks_policy == false
&& length(output.branch_rulesets["advisory-checks-repo-rules-3"].rules.required_status_checks.required_check) == 2
&& output.branch_rulesets["advisory-checks-repo-rules-3"].rules.required_status_checks.required_check[0].context == "lint / advisory"
&& output.branch_rulesets["advisory-checks-repo-rules-3"].rules.required_status_checks.required_check[1].context == "build / advisory"
)
error_message = "CI Status Advisory must populate required_status_checks from advisory_checks with non-strict create-tolerant policy"
}

assert {
condition = output.branch_rulesets["advisory-checks-repo-rules-1"].rules.required_status_checks == null
error_message = "advisory_checks must not inject required_status_checks into the active Pull Request Gate"
}
}

#endregion --- [ advisory_checks default ruleset injection ] --------------------------------- #

#region ------ [ Default value sweep (regression guard on repo_setting_defaults) ] ----------- #

# A single run block asserts every default from local.repo_setting_defaults
Expand Down
Loading