From 16681a39cf4964c31927d4a11d45dba4e3d5e15c Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Thu, 25 Dec 2025 12:24:24 +0100 Subject: [PATCH] Add GitHub immutable releases Adds functionality to enable immutable GitHub releases using a new feature configuration: ```yaml github: # Default is false. immutable_releases: true|false ``` Fixes #83 --- README.md | 12 ++++ asfyaml/feature/github/__init__.py | 3 + asfyaml/feature/github/immutable_releases.py | 53 ++++++++++++++++ asfyaml/feature/pelican.py | 2 + tests/github_immutable_releases.py | 63 ++++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 asfyaml/feature/github/immutable_releases.py create mode 100644 tests/github_immutable_releases.py diff --git a/README.md b/README.md index a5cfecc..0acc148 100644 --- a/README.md +++ b/README.md @@ -561,6 +561,18 @@ The **subject line templates** support the use of the following variables only. **Note**: If your project uses multiple GitHub repositories, we recommend using the `repository` variable to let people know which repo the email relates to. If your project has a single repo or does not use GitHub integration much (or at all), you can omit that variable. +

Immutable Releases

+ +Immutable GitHub releases make the release Git tag and the release assets immutable. +See [GitHub Docs](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases). + +To enable immutable GitHub releases: +~~~yaml +github: + # The default is false + immutable_releases: true +~~~ +

Default branch

To change the default GitHub repository branch (which is the landing branch when users browse to `github.com/apache/` and the default branch pull requests are initially based on, etc.) create an INFRA Jira ticket. If you are renaming the default branch and the new default branch does not yet exist, you can ask Infra to rename the branch at the same time. Include a **link** to the mailing list thread where the change of the default was agreed. diff --git a/asfyaml/feature/github/__init__.py b/asfyaml/feature/github/__init__.py index ec770e8..7151c9d 100644 --- a/asfyaml/feature/github/__init__.py +++ b/asfyaml/feature/github/__init__.py @@ -98,6 +98,8 @@ class ASFGitHubFeature(ASFYamlFeature, name="github"): # GitHub Pages: branch (can be default or gh-pages) and path (can be /docs or /) strictyaml.Optional("ghp_branch"): strictyaml.Str(), strictyaml.Optional("ghp_path", default="/docs"): strictyaml.Str(), + # GitHub Immutable Releases + strictyaml.Optional("immutable_releases"): strictyaml.Bool(), # Branch protection rules - TODO: add actual schema strictyaml.Optional("protected_branches"): asfyaml.validators.EmptyValue() | strictyaml.MapPattern( @@ -255,6 +257,7 @@ def run(self): autolink, features, branch_protection, + immutable_releases, pull_requests, merge_buttons, pages, diff --git a/asfyaml/feature/github/immutable_releases.py b/asfyaml/feature/github/immutable_releases.py new file mode 100644 index 0000000..a3743a4 --- /dev/null +++ b/asfyaml/feature/github/immutable_releases.py @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Settings related to GitHub Immutable Releases""" + +from github.GithubObject import NotSet, is_defined +from typing import TYPE_CHECKING +from . import directive, ASFGitHubFeature + +if TYPE_CHECKING: + from github.Requester import Requester + + +@directive +def immutable_releases(self: ASFGitHubFeature): + immutable_releases_setting = self.yaml.get("immutable_releases", NotSet) + enable = immutable_releases_setting is True if is_defined(immutable_releases_setting) else False + print(f"GitHub immutable releases shall be {'enabled' if enable else 'disabled'}") + + url = f"/repos/{self.repository.org_id}/{self.repository.name}/immutable-releases" + + if not self.noop("immutable_releases"): + requester: Requester = self.ghrepo.requester + is_enabled_status, _, _ = requester.requestJson("GET", url) + # HTTP status code 404 means that immutable releases are not enabled. + # See https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#check-if-immutable-releases-are-enabled-for-a-repository + is_enabled = is_enabled_status == 200 + print(f"GitHub immutable releases are currently {'enabled' if is_enabled else 'disabled'}") + + if is_enabled != enable: + if enable: + # https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#enable-immutable-releases + status, _, _ = requester.requestJson("PUT", url) + else: + # https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#disable-immutable-releases + status, _, _ = requester.requestJson("DELETE", url) + assert status == 204, f"Failed to set immutable releases to {enable}" + + print(f"GitHub immutable releases are now {'enabled' if enable else 'disabled'}") diff --git a/asfyaml/feature/pelican.py b/asfyaml/feature/pelican.py index e012be2..adae1f0 100644 --- a/asfyaml/feature/pelican.py +++ b/asfyaml/feature/pelican.py @@ -24,6 +24,7 @@ CI_HOSTNAME = "ci2.apache.org" CI_HOSTNAME_TEST = "ci2-test.apache.org" + class ASFPelicanFeature(ASFYamlFeature, name="pelican", env="production", priority=4): schema = strictyaml.Map( { @@ -103,6 +104,7 @@ def run(self): print(payload) print("Done!") + class ASFPelicanTestFeature(ASFYamlFeature, name="pelicantest", env="citest", priority=3): schema = strictyaml.Map( { diff --git a/tests/github_immutable_releases.py b/tests/github_immutable_releases.py new file mode 100644 index 0000000..5864d6e --- /dev/null +++ b/tests/github_immutable_releases.py @@ -0,0 +1,63 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Unit tests for .asf.yaml GitHub pull request features""" + +from helpers import YamlTest +import asfyaml.asfyaml +import asfyaml.dataobjects + +# Set .asf.yaml to debug mode +asfyaml.asfyaml.DEBUG = True + + +immutable_releases_enabled = YamlTest( + None, + None, + """ +github: + immutable_releases: true +""", +) + + +immutable_releases_disabled = YamlTest( + None, + None, + """ +github: + immutable_releases: false +""", +) + + +def test_immutable_releases(test_repo: asfyaml.dataobjects.Repository): + print("[github] Testing immutable releases") + + tests_to_run = ( + immutable_releases_enabled, + immutable_releases_disabled, + ) + + for test in tests_to_run: + with test.ctx(): + a = asfyaml.asfyaml.ASFYamlInstance( + repo=test_repo, committer="humbedooh", config_data=test.yaml, branch=asfyaml.dataobjects.DEFAULT_BRANCH + ) + a.environments_enabled.add("noop") + a.no_cache = True + a.run_parts()