Skip to content
Merged
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
18 changes: 17 additions & 1 deletion .github/release/checks.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
10 changes: 10 additions & 0 deletions .github/release/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 23 additions & 1 deletion .github/release/tests/test_checks.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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()
12 changes: 12 additions & 0 deletions .github/release/tests/test_release_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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"
Expand Down
Loading