diff --git a/actions/check-release-authorization/action.yml b/actions/check-release-authorization/action.yml new file mode 100644 index 0000000..cbeaa72 --- /dev/null +++ b/actions/check-release-authorization/action.yml @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Check release authorization +description: Verify that an actor has the consumer-required repository permission before a privileged release stage. + +inputs: + repository: + description: Repository in owner/name form. + required: true + actor: + description: GitHub login whose effective repository permission is checked. + required: true + minimum-permission: + description: Minimum required permission (read, triage, write, maintain or admin). + required: true + github-token: + description: Token used only for the repository permission lookup. + required: true + +outputs: + actual-permission: + description: Effective GitHub repository permission for the actor. + value: ${{ steps.check.outputs.actual-permission }} + authorized: + description: Whether the actor meets the configured minimum. + value: ${{ steps.check.outputs.authorized }} + +runs: + using: composite + steps: + - id: check + name: Check repository permission + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + RELEASE_REPOSITORY: ${{ inputs.repository }} + RELEASE_ACTOR: ${{ inputs.actor }} + RELEASE_MINIMUM_PERMISSION: ${{ inputs.minimum-permission }} + run: | + set -euo pipefail + + result_file="${RUNNER_TEMP}/release-authorization.json" + set +e + python3 "${GITHUB_ACTION_PATH}/../../scripts/check_release_authorization.py" \ + --repository "${RELEASE_REPOSITORY}" \ + --actor "${RELEASE_ACTOR}" \ + --minimum "${RELEASE_MINIMUM_PERMISSION}" \ + > "${result_file}" + exit_code=$? + set -e + + if [[ "${exit_code}" -ne 0 && "${exit_code}" -ne 3 ]]; then + cat "${result_file}" || true + exit "${exit_code}" + fi + + actual_permission="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["actual_permission"])' "${result_file}")" + authorized="$(python3 -c 'import json,sys; print("true" if json.load(open(sys.argv[1]))["authorized"] else "false")' "${result_file}")" + + echo "actual-permission=${actual_permission}" >> "${GITHUB_OUTPUT}" + echo "authorized=${authorized}" >> "${GITHUB_OUTPUT}" + + { + echo "## Release authorization" + echo + echo "- Actor: `${RELEASE_ACTOR}`" + echo "- Required: `${RELEASE_MINIMUM_PERMISSION}`" + echo "- Actual: `${actual_permission}`" + echo "- Authorized: **${authorized}**" + } >> "${GITHUB_STEP_SUMMARY}" + + if [[ "${authorized}" != "true" ]]; then + echo "::error::${RELEASE_ACTOR} has ${actual_permission} permission; ${RELEASE_MINIMUM_PERMISSION} is required." + exit 1 + fi diff --git a/scripts/check_release_authorization.py b/scripts/check_release_authorization.py new file mode 100755 index 0000000..41d99cc --- /dev/null +++ b/scripts/check_release_authorization.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import argparse +import json +import os +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen + +PERMISSION_RANK = { + "none": 0, + "read": 1, + "triage": 2, + "write": 3, + "maintain": 4, + "admin": 5, +} + + +def is_authorized(actual: str, minimum: str) -> bool: + if minimum not in PERMISSION_RANK: + raise ValueError(f"unsupported minimum permission: {minimum}") + if actual not in PERMISSION_RANK: + raise ValueError(f"unsupported repository permission: {actual}") + return PERMISSION_RANK[actual] >= PERMISSION_RANK[minimum] + + +def fetch_permission(repository: str, actor: str, token: str, api_url: str = "https://api.github.com") -> str: + if "/" not in repository: + raise ValueError("repository must use owner/name form") + if actor.strip() == "": + raise ValueError("actor must not be empty") + if token.strip() == "": + raise ValueError("GitHub token must not be empty") + + url = f"{api_url.rstrip('/')}/repos/{repository}/collaborators/{quote(actor, safe='')}/permission" + request = Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "LibreCodeCoop/github-workflows", + }, + ) + + try: + with urlopen(request, timeout=30) as response: + payload = json.load(response) + except HTTPError as error: + if error.code == 404: + return "none" + raise RuntimeError(f"GitHub permission lookup failed with HTTP {error.code}") from error + + permission = payload.get("permission") if isinstance(payload, dict) else None + if not isinstance(permission, str) or permission not in PERMISSION_RANK: + raise RuntimeError("GitHub returned an invalid repository permission") + return permission + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", required=True) + parser.add_argument("--actor", required=True) + parser.add_argument("--minimum", required=True) + parser.add_argument("--api-url", default=os.environ.get("GITHUB_API_URL", "https://api.github.com")) + args = parser.parse_args() + + try: + actual = fetch_permission( + args.repository, + args.actor, + os.environ.get("GITHUB_TOKEN", ""), + args.api_url, + ) + authorized = is_authorized(actual, args.minimum) + except (RuntimeError, ValueError) as error: + parser.error(str(error)) + + print(json.dumps({ + "actor": args.actor, + "repository": args.repository, + "minimum_permission": args.minimum, + "actual_permission": actual, + "authorized": authorized, + }, separators=(",", ":"))) + + return 0 if authorized else 3 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_authorization.py b/tests/test_release_authorization.py new file mode 100644 index 0000000..0a2868d --- /dev/null +++ b/tests/test_release_authorization.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "check_release_authorization.py" + +spec = importlib.util.spec_from_file_location("check_release_authorization", SCRIPT) +assert spec is not None and spec.loader is not None +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class ReleaseAuthorizationTest(unittest.TestCase): + def test_permission_order_matches_github_levels(self) -> None: + self.assertTrue(module.is_authorized("admin", "maintain")) + self.assertTrue(module.is_authorized("maintain", "maintain")) + self.assertTrue(module.is_authorized("write", "triage")) + self.assertFalse(module.is_authorized("write", "maintain")) + self.assertFalse(module.is_authorized("read", "write")) + self.assertFalse(module.is_authorized("none", "read")) + + def test_rejects_unknown_minimum_permission(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported minimum permission"): + module.is_authorized("admin", "owner") + + def test_rejects_unknown_actual_permission(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported repository permission"): + module.is_authorized("custom", "read") + + +if __name__ == "__main__": + unittest.main()