diff --git a/.gitignore b/.gitignore index 8f26be3..7343f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/DESIGN.md b/DESIGN.md index 0f4ed4e..a3caa4f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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) @@ -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` @@ -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` | | | |---|---| @@ -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` | | | |---|---| @@ -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 && 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` | | | |---|---| @@ -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: @@ -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**: @@ -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: @@ -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 | diff --git a/docs/reference/terraform.md b/docs/reference/terraform.md index 442a2c2..aae9530 100644 --- a/docs/reference/terraform.md +++ b/docs/reference/terraform.md @@ -35,7 +35,7 @@ This file is overwritten by `terraform-docs` on every PR via the | github\_token | GitHub Personal Access Token for authentication. Required when github\_auth\_mode = 'token'. Must be null when github\_auth\_mode = 'app'. | `string` | `null` | no | | repo\_default\_branches | Default branch list applied when a repo YAML does not explicitly define branches. | `list(string)` |
[
"main"
]
| no | | repo\_default\_codeowners | Default CODEOWNERS content used for personal-account repositories when a repo enables require\_code\_owner\_review but does not set 'codeowners' in its YAML. Set to null to force explicit per-repo codeowners even on personal accounts. | `string` | `null` | no | -| repo\_default\_rules | Default repository rulesets applied when a repo YAML does not explicitly define rules. |
list(
object({
name = optional(string)
target = optional(string)
enforcement = optional(string)

bypass_actors = optional(
list(
object({
actor_id = number
actor_type = string
bypass_mode = optional(string)
})
),
[]
)

conditions = optional(
object({
include = list(string)
exclude = optional(list(string), [])
})
)

rules = object({
creation = optional(bool)
update = optional(bool)
deletion = optional(bool)
non_fast_forward = optional(bool)
required_linear_history = optional(bool)
required_signatures = optional(bool)

pull_request = optional(
object({
allowed_merge_methods = list(string)
dismiss_stale_reviews_on_push = optional(bool)
require_code_owner_review = optional(bool)
require_last_push_approval = optional(bool)
required_approving_review_count = optional(number)
required_review_thread_resolution = optional(bool)
})
)

copilot_code_review = optional(
object({
review_on_push = optional(bool)
review_draft_pull_requests = optional(bool)
})
)

required_status_checks = optional(
object({
required_check = optional(
list(
object({
context = string
integration_id = optional(number)
})
),
[]
)

do_not_enforce_on_create = optional(bool)
strict_required_status_checks_policy = optional(bool)
})
)

required_deployments = optional(
object({
required_deployment_environments = list(string)
})
)

required_code_scanning = optional(
object({
required_code_scanning_tool = list(
object({
tool = string
alerts_threshold = optional(string)
security_alerts_threshold = optional(string)
})
)
})
)

merge_queue = optional(
object({
check_response_timeout_minutes = optional(number)
grouping_strategy = optional(string)
max_entries_to_build = optional(number)
max_entries_to_merge = optional(number)
merge_method = optional(string)
min_entries_to_merge = optional(number)
min_entries_to_merge_wait_minutes = optional(number)
})
)

branch_name_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

commit_author_email_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

committer_email_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

commit_message_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

tag_name_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

update_allows_fetch_and_merge = optional(bool)

file_path_restriction = optional(
object({
restricted_file_paths = list(string)
})
)

file_extension_restriction = optional(
object({
restricted_file_extensions = list(string)
})
)

max_file_size = optional(
object({
max_file_size = number
})
)

max_file_path_length = optional(
object({
max_file_path_length = number
})
)
})
})
)
|
[
{
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"conditions": {
"exclude": [],
"include": [
"~DEFAULT_BRANCH"
]
},
"enforcement": "active",
"name": "Branch Safety",
"rules": {
"creation": true,
"deletion": true,
"non_fast_forward": true,
"required_linear_history": true,
"required_signatures": true,
"update": true
},
"target": "branch"
},
{
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"conditions": {
"exclude": [],
"include": [
"~DEFAULT_BRANCH"
]
},
"enforcement": "active",
"name": "Pull Request Gate",
"rules": {
"pull_request": {
"allowed_merge_methods": [
"squash"
],
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_approving_review_count": 1,
"required_review_thread_resolution": true
}
},
"target": "branch"
},
{
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"conditions": {
"exclude": [],
"include": [
"~ALL"
]
},
"enforcement": "active",
"name": "Release Tag Protection",
"rules": {
"deletion": true,
"non_fast_forward": true,
"required_signatures": true
},
"target": "tag"
}
]
| no | +| repo\_default\_rules | Default repository rulesets applied when a repo YAML does not explicitly define rules. |
list(
object({
name = optional(string)
target = optional(string)
enforcement = optional(string)

bypass_actors = optional(
list(
object({
actor_id = number
actor_type = string
bypass_mode = optional(string)
})
),
[]
)

conditions = optional(
object({
include = list(string)
exclude = optional(list(string), [])
})
)

rules = object({
creation = optional(bool)
update = optional(bool)
deletion = optional(bool)
non_fast_forward = optional(bool)
required_linear_history = optional(bool)
required_signatures = optional(bool)

pull_request = optional(
object({
allowed_merge_methods = list(string)
dismiss_stale_reviews_on_push = optional(bool)
require_code_owner_review = optional(bool)
require_last_push_approval = optional(bool)
required_approving_review_count = optional(number)
required_review_thread_resolution = optional(bool)
})
)

copilot_code_review = optional(
object({
review_on_push = optional(bool)
review_draft_pull_requests = optional(bool)
})
)

required_status_checks = optional(
object({
required_check = optional(
list(
object({
context = string
integration_id = optional(number)
})
),
[]
)

do_not_enforce_on_create = optional(bool)
strict_required_status_checks_policy = optional(bool)
})
)

required_deployments = optional(
object({
required_deployment_environments = list(string)
})
)

required_code_scanning = optional(
object({
required_code_scanning_tool = list(
object({
tool = string
alerts_threshold = optional(string)
security_alerts_threshold = optional(string)
})
)
})
)

merge_queue = optional(
object({
check_response_timeout_minutes = optional(number)
grouping_strategy = optional(string)
max_entries_to_build = optional(number)
max_entries_to_merge = optional(number)
merge_method = optional(string)
min_entries_to_merge = optional(number)
min_entries_to_merge_wait_minutes = optional(number)
})
)

branch_name_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

commit_author_email_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

committer_email_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

commit_message_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

tag_name_pattern = optional(
object({
operator = string
pattern = string
name = optional(string)
negate = optional(bool)
})
)

update_allows_fetch_and_merge = optional(bool)

file_path_restriction = optional(
object({
restricted_file_paths = list(string)
})
)

file_extension_restriction = optional(
object({
restricted_file_extensions = list(string)
})
)

max_file_size = optional(
object({
max_file_size = number
})
)

max_file_path_length = optional(
object({
max_file_path_length = number
})
)
})
})
)
|
[
{
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"conditions": {
"exclude": [],
"include": [
"~DEFAULT_BRANCH"
]
},
"enforcement": "active",
"name": "Branch Safety",
"rules": {
"creation": true,
"deletion": true,
"non_fast_forward": true,
"required_linear_history": true,
"required_signatures": true,
"update": true
},
"target": "branch"
},
{
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"conditions": {
"exclude": [],
"include": [
"~DEFAULT_BRANCH"
]
},
"enforcement": "active",
"name": "Pull Request Gate",
"rules": {
"pull_request": {
"allowed_merge_methods": [
"squash"
],
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_approving_review_count": 1,
"required_review_thread_resolution": true
}
},
"target": "branch"
},
{
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"conditions": {
"exclude": [],
"include": [
"~ALL"
]
},
"enforcement": "active",
"name": "Release Tag Protection",
"rules": {
"deletion": true,
"non_fast_forward": true,
"required_signatures": true
},
"target": "tag"
},
{
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"conditions": {
"exclude": [],
"include": [
"~DEFAULT_BRANCH"
]
},
"enforcement": "evaluate",
"name": "CI Status Advisory",
"rules": {
"required_status_checks": {
"do_not_enforce_on_create": true,
"required_check": [],
"strict_required_status_checks_policy": false
}
},
"target": "branch"
}
]
| no | | repo\_yaml\_path | Path (relative to the module root) containing the repos/ tree to ingest. Default 'repos' matches the production layout. Overridden by terraform test runs to point at fixture directories under tests/fixtures/. | `string` | `"repos"` | no | | security\_baseline | Desired security\_and\_analysis baseline the framework should enforce when the owner's github\_security\_capabilities allow it, keyed by visibility. Fully-required matrix. A feature set to true means 'enable this wherever capabilities permit'; false means 'leave this feature unmanaged (do not enable)'. Explicit per-repo security\_and\_analysis YAML still overrides this baseline. |
object({
public = object({
advanced_security = bool
code_security = bool
secret_scanning = bool
secret_scanning_push_protection = bool
secret_scanning_ai_detection = bool
secret_scanning_non_provider_patterns = bool
})
private = object({
advanced_security = bool
code_security = bool
secret_scanning = bool
secret_scanning_push_protection = bool
secret_scanning_ai_detection = bool
secret_scanning_non_provider_patterns = bool
})
internal = object({
advanced_security = bool
code_security = bool
secret_scanning = bool
secret_scanning_push_protection = bool
secret_scanning_ai_detection = bool
secret_scanning_non_provider_patterns = bool
})
})
|
{
"internal": {
"advanced_security": true,
"code_security": true,
"secret_scanning": true,
"secret_scanning_ai_detection": true,
"secret_scanning_non_provider_patterns": true,
"secret_scanning_push_protection": true
},
"private": {
"advanced_security": true,
"code_security": true,
"secret_scanning": true,
"secret_scanning_ai_detection": true,
"secret_scanning_non_provider_patterns": true,
"secret_scanning_push_protection": true
},
"public": {
"advanced_security": false,
"code_security": false,
"secret_scanning": true,
"secret_scanning_ai_detection": true,
"secret_scanning_non_provider_patterns": true,
"secret_scanning_push_protection": true
}
}
| no | | security\_baseline\_mode | Controls how the framework reconciles repo security\_and\_analysis against github\_security\_capabilities. 'strict' fails plan when a required baseline setting exceeds declared capabilities. 'compatibility' emits an advisory preview via a check block and leaves unsupported settings unset. Default is 'compatibility' to allow non-breaking rollout; flip to 'strict' in the next tagged release. | `string` | `"compatibility"` | no | diff --git a/terraform/11-variables.github.tf b/terraform/11-variables.github.tf index 6a8f9fd..c705c3c 100644 --- a/terraform/11-variables.github.tf +++ b/terraform/11-variables.github.tf @@ -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 + } + } } ] } diff --git a/terraform/30-locals.tf b/terraform/30-locals.tf index e7b7d1c..45af178 100644 --- a/terraform/30-locals.tf +++ b/terraform/30-locals.tf @@ -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 @@ -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: @@ -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 @@ -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 + ) ) ) @@ -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 diff --git a/terraform/tests/COVERAGE.md b/terraform/tests/COVERAGE.md index d36df01..bae4988 100644 --- a/terraform/tests/COVERAGE.md +++ b/terraform/tests/COVERAGE.md @@ -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 diff --git a/terraform/tests/fixtures/good-advisory-checks/public/repo.yml b/terraform/tests/fixtures/good-advisory-checks/public/repo.yml new file mode 100644 index 0000000..c461a00 --- /dev/null +++ b/terraform/tests/fixtures/good-advisory-checks/public/repo.yml @@ -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" diff --git a/terraform/tests/normalization.tftest.hcl b/terraform/tests/normalization.tftest.hcl index 733b0a6..95f4f4d 100644 --- a/terraform/tests/normalization.tftest.hcl +++ b/terraform/tests/normalization.tftest.hcl @@ -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