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
76 changes: 76 additions & 0 deletions actions/check-release-authorization/action.yml
Original file line number Diff line number Diff line change
@@ -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
96 changes: 96 additions & 0 deletions scripts/check_release_authorization.py
Original file line number Diff line number Diff line change
@@ -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())
38 changes: 38 additions & 0 deletions tests/test_release_authorization.py
Original file line number Diff line number Diff line change
@@ -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()
Loading