From f1fd590dcdf7408c5954ffc5dad690aede04990f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 21 Oct 2026 21:33:41 +0300 Subject: [PATCH] Stop a production release when the publish environment has no approval rule GitHub creates an environment that a job names on first use, with no required reviewers and no deployment rules. For a production release the plan job now reads the environment the publish job will run in and stops the release unless it requires a reviewer and limits deployments to selected branches and tags. A maven-release that has its secrets but no approval rule cannot sign and upload unattended, and a missing one stops here instead of after the build. A protected-branches-only policy does not count: releases run on tags, which it would refuse only after the build. --- .github/release/checks.py | 18 ++++++++++++++++- .github/release/release.py | 10 ++++++++++ .github/release/tests/test_checks.py | 24 ++++++++++++++++++++++- .github/release/tests/test_release_cli.py | 12 ++++++++++++ .github/workflows/release.yml | 12 ++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/.github/release/checks.py b/.github/release/checks.py index 7ec668b6..5a42c69b 100644 --- a/.github/release/checks.py +++ b/.github/release/checks.py @@ -1,4 +1,4 @@ -"""Checks on the tag commit: its release branch and the required status checks.""" +"""Checks before a release: the tag commit's release branch, its required status checks and the publish environment.""" import subprocess @@ -20,3 +20,19 @@ def missing_checks(check_runs, required): if not conclusions or any(conclusion != "success" for conclusion in conclusions): problems.append(name) return problems + + +def environment_problems(environment): + """Problems unless the deployment environment, as the GitHub environments API returns it, makes a reviewer approve + the publish job and limits deployments to selected branches and tags. GitHub creates an environment a job names on + first use with neither rule, and once the signing and upload secrets are added to it the job would publish + unattended. Releases run on tags, which a protected-branches-only policy would refuse after the build, so only + selected rules count.""" + name = environment.get("name") + rules = environment.get("protection_rules") or [] + problems = [] + if not any(rule.get("type") == "required_reviewers" and rule.get("reviewers") for rule in rules): + problems.append(f"environment {name} does not require a reviewer to approve the publish job") + if not (environment.get("deployment_branch_policy") or {}).get("custom_branch_policies"): + problems.append(f"environment {name} does not limit deployments to selected branches and tags") + return problems diff --git a/.github/release/release.py b/.github/release/release.py index 3e98d00c..901c1aea 100644 --- a/.github/release/release.py +++ b/.github/release/release.py @@ -149,6 +149,12 @@ def cmd_check_required(args): finish([f"required check '{name}' did not succeed on the tag commit" for name in checks.missing_checks(runs, config["requiredChecks"])], "Required checks") +def cmd_check_environment(args): + """The environment of the publish job must make a reviewer approve it and limit which refs deploy to it.""" + environment = json.loads(Path(args.environment).read_text(encoding="utf-8")) + finish(checks.environment_problems(environment), "Publish environment") + + def cmd_stage(args): """Removes Gradle's indexes, places the SBOMs, checks the file set and writes the manifest.""" config, plan = read_plan(args.plan) @@ -339,6 +345,10 @@ def main(argv=None): command.add_argument("--check-runs", required=True) command.set_defaults(func=cmd_check_required) + command = commands.add_parser("check-environment") + command.add_argument("--environment", required=True) + command.set_defaults(func=cmd_check_environment) + command = commands.add_parser("stage") command.add_argument("--plan", required=True) command.add_argument("--staging", required=True) diff --git a/.github/release/tests/test_checks.py b/.github/release/tests/test_checks.py index d7102958..bcf80519 100644 --- a/.github/release/tests/test_checks.py +++ b/.github/release/tests/test_checks.py @@ -1,9 +1,11 @@ import subprocess import unittest -from checks import branch_problems, missing_checks +from checks import branch_problems, environment_problems, missing_checks COMMIT = "c" * 40 +REVIEWERS = {"type": "required_reviewers", "reviewers": [{"type": "User", "reviewer": {"login": "releaser"}}]} +TAG_POLICY = {"protected_branches": False, "custom_branch_policies": True} class ChecksTest(unittest.TestCase): @@ -38,6 +40,26 @@ def elsewhere(args, **kwargs): self.assertEqual(calls[0], ["git", "-C", "/repo", "merge-base", "--is-ancestor", COMMIT, "origin/staging"]) self.assertEqual(branch_problems(COMMIT, "master", "/repo", runner=elsewhere), [f"{COMMIT} is not on origin/master; the tag must point to that branch"]) + def test_publish_environment_needs_a_reviewer_and_a_deployment_policy(self): + """An environment GitHub created on first use, without rules, fails on both counts.""" + protected = {"name": "maven-release", "protection_rules": [REVIEWERS, {"type": "branch_policy"}], "deployment_branch_policy": TAG_POLICY} + self.assertEqual(environment_problems(protected), []) + created_on_first_use = {"name": "maven-release", "protection_rules": [], "deployment_branch_policy": None} + self.assertEqual(environment_problems(created_on_first_use), [ + "environment maven-release does not require a reviewer to approve the publish job", + "environment maven-release does not limit deployments to selected branches and tags", + ]) + + def test_protected_branches_only_does_not_count_as_a_tag_rule(self): + """A release runs on a tag, which a protected-branches-only policy refuses after the build.""" + environment = {"name": "maven-release", "protection_rules": [REVIEWERS], "deployment_branch_policy": {"protected_branches": True, "custom_branch_policies": False}} + self.assertEqual(environment_problems(environment), ["environment maven-release does not limit deployments to selected branches and tags"]) + + def test_a_reviewer_rule_without_reviewers_does_not_count(self): + """A reviewer rule whose list is empty, or a wait timer alone, approves nothing.""" + environment = {"name": "maven-release", "protection_rules": [{"type": "required_reviewers", "reviewers": []}, {"type": "wait_timer", "wait_timer": 5}], "deployment_branch_policy": TAG_POLICY} + self.assertEqual(environment_problems(environment), ["environment maven-release does not require a reviewer to approve the publish job"]) + if __name__ == "__main__": unittest.main() diff --git a/.github/release/tests/test_release_cli.py b/.github/release/tests/test_release_cli.py index f9cec37e..43225dde 100644 --- a/.github/release/tests/test_release_cli.py +++ b/.github/release/tests/test_release_cli.py @@ -60,6 +60,18 @@ def test_prerelease_flag_must_match(self): result = run_cli("plan", "--tag", tag, "--target", "production", "--prerelease", prerelease, "--out", str(self.folder / "plan.json")) self.assertEqual(result.returncode, code, result.stdout) + def test_check_environment_reads_the_api_answer_and_fails_an_unprotected_environment(self): + """The workflow saves the environments API answer and passes its path; an unprotected environment fails the step.""" + protected = {"name": "maven-release", "protection_rules": [{"type": "required_reviewers", "reviewers": [{"type": "User"}]}], + "deployment_branch_policy": {"protected_branches": False, "custom_branch_policies": True}} + unprotected = {"name": "maven-release", "protection_rules": [], "deployment_branch_policy": None} + for environment, code in ((protected, 0), (unprotected, 1)): + with self.subTest(code=code): + path = self.folder / "environment.json" + path.write_text(json.dumps(environment), encoding="utf-8") + result = run_cli("check-environment", "--environment", str(path)) + self.assertEqual(result.returncode, code, result.stdout + result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d95216c3..5ac4e40d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ jobs: permissions: contents: read checks: read + actions: read outputs: environment: ${{ steps.plan.outputs.environment }} check_tasks: ${{ steps.plan.outputs.check_tasks }} @@ -55,6 +56,17 @@ jobs: - name: Plan id: plan run: python3 .github/release/release.py plan --tag "$TAG" --target "$TARGET" --commit "$GITHUB_SHA" --prerelease "${{ steps.release.outputs.prerelease || 'unknown' }}" --out plan.json + - name: The publish environment requires approval + if: env.TARGET == 'production' + env: + GH_TOKEN: ${{ github.token }} + ENVIRONMENT: ${{ steps.plan.outputs.environment }} + run: | + if ! gh api "repos/$GITHUB_REPOSITORY/environments/$ENVIRONMENT" > environment.json; then + echo "::error::Could not read the $ENVIRONMENT environment (the error above says why); it has to exist with required reviewers and selected tag deployment rules before releasing." + exit 1 + fi + python3 .github/release/release.py check-environment --environment environment.json - name: The tag commit is on the release branch if: env.TARGET == 'production' run: python3 .github/release/release.py check-branch --plan plan.json --commit "$GITHUB_SHA"