Skip to content

Commit 3d2d982

Browse files
feat: add immutable release support for repositories and org settings (#663)
Manage GitHub immutable release settings at both repository and organization level, including selected repository enforcement. Coerce repo settings against org policy during diff/apply and add schema, docs and test updates for the new configuration fields. Signed-off-by: Dan Calavrezo <195309321+dcalavrezo-qorix@users.noreply.github.com>
1 parent 4a42bcd commit 3d2d982

17 files changed

Lines changed: 368 additions & 6 deletions

File tree

docs/reference/organization/repository/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Definition of a Repository for a GitHub organization, the following properties a
4646
| _template_repository_ | string or null | The template repository to use when creating the repo | read-only, only considered during creation |
4747
| _forked_repository_ | string or null | The repository to fork when creating the repo | only considered during creation |
4848
| _fork_default_branch_only_ | boolean | When creating a fork, whether only the default branch will be included in the fork | only considered during creation |
49+
| _immutable_releases_enabled_ | boolean | If immutable releases are enabled for this repository | Can be overridden by organization-level immutable release settings |
4950
| _web_commit_signoff_required_ | boolean | If the repo requires web commit signoff | |
5051
| _workflows_ | [Workflow Settings](#workflow-settings) | Workflow settings on organizational level | |
5152
| _webhooks_ | list\[[Webhook](webhook.md)\] | webhooks defined for this repo, see section above for details | |

docs/reference/organization/settings.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ The following table captures all supported settings on organization level:
1414
| _default_branch_name_ | string | The default branch name for newly created repositories | |
1515
| _default_repository_permission_ | string | The base permission for all members of the organization for its repositories | `none`, `read`, `write` or `admin` |
1616
| _default_code_security_configurations_disabled_ | boolean | If default code security configuration should be disabled, no processing if the setting is set to `true` | |
17+
| _immutable_releases_enforced_repositories_ | string | Defines which repositories must use immutable releases | `all`, `none` or `selected` |
18+
| _immutable_releases_selected_repositories_ | list[string] | The list of repositories for which immutable releases are enforced | Only taken into account when `immutable_releases_enforced_repositories` is set to `selected` |
1719
| _discussion_source_repository_ | string or null | The source repository to host organization discussions | |
1820
| _has_discussions_ | boolean | If discussions are enabled for the organization. If `true`, property `discussion_source_repository` must be set as well | |
1921
| _has_organization_projects_ | boolean | If the organization can have organization projects | |
@@ -58,6 +60,7 @@ The following table captures all supported settings on organization level:
5860
- enabling either `dependabot_alerts_enabled_for_new_repositories` or `dependabot_security_updates_enabled_for_new_repositories` also requires enabling `dependency_graph_enabled_for_new_repositories`
5961
- enabling `dependabot_security_updates_enabled_for_new_repositories` also requires enabling `dependabot_alerts_enabled_for_new_repositories`
6062
- enabling `has_discussions` also requires setting `discussion_source_repository` to a valid repository to host the discussions
63+
- specifying a non-empty list of `immutable_releases_selected_repositories` while `immutable_releases_enforced_repositories` is not set to `selected`, triggers a warning
6164
- specifying a non-empty list of `selected_repositories` while `enabled_repositories` is not set to `selected`, triggers a warning
6265
- specifying a non-empty list of `allow_action_patterns` while `allowed_actions` is not set to `selected`, triggers a warning
6366

docs/userguide/unsupported.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Not all features or settings of a GitHub organization and its associated resourc
22
by the means of `otterdog`. The following non-exhaustive list outlines settings that are currently not supported:
33

44
- setting the profile picture of an organization or a social media preview for a repository [#119](https://github.com/eclipse-csi/otterdog/issues/119)
5-
- manage items that should be visible on the main page, i.e. releases, packages, deployments
5+
- manage release entries, packages, or deployments that should be visible on the main page
66
- manage default repository labels on organization level
77
- secrets and variables for codespaces and dependabot
88
- managing GitHub apps or oauth policies

otterdog/models/organization_settings.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ class OrganizationSettings(ModelObject):
6868
two_factor_requirement: bool = dataclasses.field(metadata={"read_only": True})
6969
web_commit_signoff_required: bool
7070
default_code_security_configurations_disabled: bool
71+
immutable_releases_enforced_repositories: str
72+
immutable_releases_selected_repositories: list[str]
7173
members_can_create_private_repositories: bool
7274
members_can_create_public_repositories: bool
7375
members_can_fork_private_repositories: bool
@@ -92,6 +94,9 @@ def model_object_name(self) -> str:
9294
return "settings"
9395

9496
def include_field_for_diff_computation(self, field: dataclasses.Field) -> bool:
97+
if field.name == "immutable_releases_selected_repositories":
98+
return self.immutable_releases_enforced_repositories == "selected"
99+
95100
if self.has_discussions is False and field.name == "discussion_source_repository":
96101
return False
97102

@@ -128,6 +133,28 @@ def validate(self, context: ValidationContext, parent_object: Any) -> None:
128133
"enabling 'members_can_create_private_pages' requires an 'enterprise' plan.",
129134
)
130135

136+
if is_set_and_valid(self.immutable_releases_enforced_repositories):
137+
if self.immutable_releases_enforced_repositories not in {"all", "none", "selected"}:
138+
context.add_failure(
139+
FailureType.ERROR,
140+
f"'immutable_releases_enforced_repositories' has value "
141+
f"'{self.immutable_releases_enforced_repositories}', "
142+
f"while only values ('all' | 'none' | 'selected') are allowed.",
143+
)
144+
145+
if (
146+
self.immutable_releases_enforced_repositories != "selected"
147+
and is_set_and_valid(self.immutable_releases_selected_repositories)
148+
and len(self.immutable_releases_selected_repositories) > 0
149+
):
150+
context.add_failure(
151+
FailureType.WARNING,
152+
f"'immutable_releases_enforced_repositories' is set to "
153+
f"'{self.immutable_releases_enforced_repositories}', "
154+
f"but 'immutable_releases_selected_repositories' is set to "
155+
f"'{self.immutable_releases_selected_repositories}', setting will be ignored.",
156+
)
157+
131158
if is_set_and_valid(self.default_repository_permission):
132159
if self.default_repository_permission not in {
133160
"none",

otterdog/models/repository.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class Repository(ModelObject):
8686
archived: bool
8787
allow_forking: bool
8888
web_commit_signoff_required: bool
89+
immutable_releases_enabled: bool
8990
secret_scanning: str
9091
secret_scanning_push_protection: str
9192
dependabot_alerts_enabled: bool
@@ -281,6 +282,9 @@ def coerce_from_org_settings(self, org_settings: OrganizationSettings, for_patch
281282
if org_settings.plan != "enterprise":
282283
copy.gh_pages_visibility = UNSET # type: ignore
283284

285+
if self._org_enforces_immutable_releases(org_settings):
286+
copy.immutable_releases_enabled = UNSET # type: ignore
287+
284288
if is_set_and_present(self.custom_properties):
285289
for custom_property in org_settings.custom_properties:
286290
current_property_value = self.custom_properties.get(custom_property.name, None)
@@ -303,6 +307,16 @@ def coerce_from_org_settings(self, org_settings: OrganizationSettings, for_patch
303307

304308
return copy
305309

310+
def _org_enforces_immutable_releases(self, org_settings: OrganizationSettings) -> bool:
311+
if org_settings.immutable_releases_enforced_repositories == "all":
312+
return True
313+
314+
return (
315+
org_settings.immutable_releases_enforced_repositories == "selected"
316+
and is_set_and_valid(org_settings.immutable_releases_selected_repositories)
317+
and self.name in org_settings.immutable_releases_selected_repositories
318+
)
319+
306320
def requires_language_validation(self) -> bool:
307321
return (
308322
self.code_scanning_default_setup_enabled is True
@@ -356,7 +370,8 @@ async def validate_code_scanning_languages(self, context: ValidationContext, par
356370
context.add_failure(
357371
FailureType.ERROR,
358372
f"{self.get_model_header(parent_object)} has 'code_scanning_default_languages' "
359-
f"configured with '{configured_language}' but this language is not detected in the repository. "
373+
f"configured with '{configured_language}' but this language is not detected "
374+
f"in the repository. "
360375
f"Detected languages: {', '.join(sorted(languages.keys()))}",
361376
)
362377
except Exception as e:
@@ -454,6 +469,13 @@ def validate(self, context: ValidationContext, parent_object: Any) -> None:
454469
f"the organization requires it, setting will be ignored.",
455470
)
456471

472+
if self.immutable_releases_enabled is False and self._org_enforces_immutable_releases(org_settings):
473+
context.add_failure(
474+
FailureType.INFO,
475+
f"{self.get_model_header()} has 'immutable_releases_enabled' disabled, while "
476+
f"the organization enforces immutable releases for this repository, setting will be ignored.",
477+
)
478+
457479
secret_scanning_disabled = self.secret_scanning == "disabled"
458480
secret_scanning_push_protection_enabled = self.secret_scanning_push_protection == "enabled"
459481
if secret_scanning_disabled and secret_scanning_push_protection_enabled and self.archived is False:
@@ -901,6 +923,7 @@ def transform_perm(permissions):
901923
"branch_protection_rules": K([]),
902924
"rulesets": K([]),
903925
"environments": K([]),
926+
"immutable_releases_enabled": OptionalS("immutable_releases_enabled", default=UNSET),
904927
"secret_scanning": OptionalS("security_and_analysis", "secret_scanning", "status", default=UNSET),
905928
"secret_scanning_push_protection": OptionalS(
906929
"security_and_analysis",
@@ -1210,6 +1233,7 @@ def generate_live_patch(
12101233
if current_object is None:
12111234
handler(LivePatch.of_addition(coerced_object, parent_object, coerced_object.apply_live_patch))
12121235
else:
1236+
raw_current_object = current_object
12131237
if context.current_org_settings is not None:
12141238
current_org_settings = cast("OrganizationSettings", context.current_org_settings)
12151239
current_object = current_object.coerce_from_org_settings(current_org_settings)
@@ -1237,6 +1261,19 @@ def generate_live_patch(
12371261
if k not in to_value:
12381262
change.to_value[k] = None
12391263

1264+
if (
1265+
context.current_org_settings is not None
1266+
and raw_current_object._org_enforces_immutable_releases(
1267+
cast("OrganizationSettings", context.current_org_settings)
1268+
)
1269+
and not expected_object._org_enforces_immutable_releases(expected_org_settings)
1270+
and is_set_and_valid(coerced_object.immutable_releases_enabled)
1271+
):
1272+
modified_repo["immutable_releases_enabled"] = Change(
1273+
raw_current_object.immutable_releases_enabled,
1274+
coerced_object.immutable_releases_enabled,
1275+
)
1276+
12401277
if len(modified_repo) > 0:
12411278
if "archived" in modified_repo:
12421279
change = modified_repo["archived"]

0 commit comments

Comments
 (0)