Skip to content
Open
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -889,8 +889,9 @@ Notes:
- `required_status_checks_strict` defaults to `false` (set it to `true` to require branches to be up to date before merge).
- If `branches`/`refs` is omitted for `type: branch`, it defaults to `~DEFAULT_BRANCH`.
- `restrict_deletion` and `restrict_force_push` default to `true` in convenience syntax.
- Set `restrict_deletion: false` and/or `restrict_force_push: false` to disable those rules for a convenience entry.
- `deletion` and `non_fast_forward` are raw rule types under `rules`, not convenience top-level keys.
- `restrict_update` defaults to `true` for `type: tag` and to `false` for `type: branch`.
- Set `restrict_deletion: false`, `restrict_force_push: false` and/or `restrict_update: false` to disable those rules for a convenience entry.
- `deletion`, `non_fast_forward` and `update` are raw rule types under `rules`, not convenience top-level keys.
- Convenience entries may intentionally resolve to `rules: []` (for example, while staging migration changes).
- If `required_conversation_resolution` is set, `required_pull_request_reviews` must be present as well.

Expand Down Expand Up @@ -975,6 +976,9 @@ github:
excludes: []
~~~

By default, a tag ruleset blocks deletion, force-pushes, and updates of matching tags for everyone without bypass permission,
matching the semantics of the old `protected_tags` setting.

<h3 id="merge">Merge buttons</h3>

Projects can enable/disable the `merge PR` button in the GitHub UI and configure which actions to allow by adding the following configuration (or derivatives thereof):
Expand Down
7 changes: 5 additions & 2 deletions asfyaml/feature/github/rulesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"bypass_mode",
"restrict_deletion",
"restrict_force_push",
"restrict_update",
"required_signatures",
"required_linear_history",
"required_conversation_resolution",
Expand Down Expand Up @@ -413,9 +414,9 @@ def _is_raw_ruleset_definition(ruleset: dict[str, Any]) -> bool:
return any(key in ruleset for key in _RAW_RULESET_KEYS)


def _is_safety_rule_enabled(ruleset: dict[str, Any], field_name: str) -> bool:
def _is_safety_rule_enabled(ruleset: dict[str, Any], field_name: str, *, default: bool = True) -> bool:
if field_name not in ruleset:
return True
return default
return _expect_bool(ruleset.get(field_name), field_name)


Expand Down Expand Up @@ -495,6 +496,8 @@ def _to_payload_ruleset(
rules.append({"type": "deletion"})
if _is_safety_rule_enabled(ruleset, "restrict_force_push"):
rules.append({"type": "non_fast_forward"})
if _is_safety_rule_enabled(ruleset, "restrict_update", default=(target == "tag")):
rules.append({"type": "update"})

pull_request_rule = _build_pull_request_rule(ruleset)
if pull_request_rule:
Expand Down
125 changes: 114 additions & 11 deletions tests/github_rulesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ def test_rulesets_convenience_tag_restrict_force_push_rule():
rule_types = [rule["type"] for rule in payload["rules"]]
assert "deletion" in rule_types
assert "non_fast_forward" in rule_types
assert "update" in rule_types


def test_rulesets_convenience_minimal_config_includes_safety_rules():
Expand All @@ -449,6 +450,101 @@ def test_rulesets_convenience_minimal_config_includes_safety_rules():
assert rule_types == ["deletion", "non_fast_forward"]


def test_rulesets_convenience_minimal_tag_config_includes_safety_rules():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Tag safety defaults only",
"type": "tag",
"branches": {"includes": ["v*.*.*"]},
}
]
},
previous_yaml={},
requester=requester,
)

configure_rulesets(feature)

payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert rule_types == ["deletion", "non_fast_forward", "update"]


def test_rulesets_convenience_restrict_update_false_disables_tag_rule():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Release tags",
"type": "tag",
"branches": {"includes": ["v*.*.*"]},
"restrict_update": False,
}
]
},
previous_yaml={},
requester=requester,
)

configure_rulesets(feature)

payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert rule_types == ["deletion", "non_fast_forward"]


def test_rulesets_convenience_restrict_update_true_enables_branch_rule():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"restrict_update": True,
}
]
},
previous_yaml={},
requester=requester,
)

configure_rulesets(feature)

payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert rule_types == ["deletion", "non_fast_forward", "update"]


def test_rulesets_convenience_all_safety_rules_false_allows_empty_tag_rules():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Release tags",
"type": "tag",
"branches": {"includes": ["v*.*.*"]},
"restrict_deletion": False,
"restrict_force_push": False,
"restrict_update": False,
}
]
},
previous_yaml={},
requester=requester,
)

configure_rulesets(feature)

payload = requester.calls[1]["input"]
assert payload["rules"] == []


def test_rulesets_convenience_restrict_deletion_false_disables_rule():
requester = FakeRequester()
feature = FakeFeature(
Expand Down Expand Up @@ -523,15 +619,19 @@ def test_rulesets_convenience_both_safety_rules_false_allows_empty_rules():


@pytest.mark.parametrize(
("field_name", "field_value"),
("field_name", "field_value", "expected_rule_type"),
[
("restrict_deletion", True),
("restrict_deletion", "true"),
("restrict_force_push", True),
("restrict_force_push", "true"),
("restrict_deletion", True, "deletion"),
("restrict_deletion", "true", "deletion"),
("restrict_force_push", True, "non_fast_forward"),
("restrict_force_push", "true", "non_fast_forward"),
("restrict_update", True, "update"),
("restrict_update", "true", "update"),
],
)
def test_rulesets_convenience_allow_explicit_safety_true(field_name: str, field_value: bool | str):
def test_rulesets_convenience_allow_explicit_safety_true(
field_name: str, field_value: bool | str, expected_rule_type: str
):
requester = FakeRequester()
feature = FakeFeature(
yaml={
Expand All @@ -552,13 +652,12 @@ def test_rulesets_convenience_allow_explicit_safety_true(field_name: str, field_

payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert "deletion" in rule_types
assert "non_fast_forward" in rule_types
assert expected_rule_type in rule_types


@pytest.mark.parametrize(
"field_name",
["restrict_deletion", "restrict_force_push"],
["restrict_deletion", "restrict_force_push", "restrict_update"],
)
def test_rulesets_convenience_reject_invalid_safety_type(field_name: str):
requester = FakeRequester()
Expand Down Expand Up @@ -800,7 +899,11 @@ def test_rulesets_duplicate_names_raise():
configure_rulesets(feature)


def test_rulesets_mixed_raw_and_convenience_keys_raise():
@pytest.mark.parametrize(
"convenience_key",
["required_signatures", "restrict_update"],
)
def test_rulesets_mixed_raw_and_convenience_keys_raise(convenience_key: str):
requester = FakeRequester()
feature = FakeFeature(
yaml={
Expand All @@ -809,7 +912,7 @@ def test_rulesets_mixed_raw_and_convenience_keys_raise():
"name": "Branch Protection",
"target": "branch",
"rules": [{"type": "required_signatures"}],
"required_signatures": True,
convenience_key: True,
}
]
},
Expand Down
Loading