diff --git a/.github/actions/check-github-config/action.yml b/.github/actions/check-github-config/action.yml new file mode 100644 index 0000000..61789ea --- /dev/null +++ b/.github/actions/check-github-config/action.yml @@ -0,0 +1,18 @@ +name: check-github-config +description: Check repository settings against the baseline GitHub configuration. +inputs: + skip: + description: JSON array of GitHub repository config checks to skip. + default: "[]" +runs: + using: composite + steps: + - run: python3 -m pip install pyyaml + shell: bash + - run: python3 "${{ github.action_path }}/check.py" + shell: bash + env: + CONFIG_PATH: ${{ github.action_path }}/../../../config/github.yml + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SKIP: ${{ inputs.skip }} diff --git a/.github/actions/check-github-config/check.py b/.github/actions/check-github-config/check.py new file mode 100644 index 0000000..1bd6485 --- /dev/null +++ b/.github/actions/check-github-config/check.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import yaml + +FIELD_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +@dataclass(frozen=True) +class CheckResult: + name: str + status: str + message: str + + +def parse_json_list(value): + try: + items = json.loads(value) + except json.JSONDecodeError as error: + raise ValueError("skip must be a JSON array of strings") from error + + if not isinstance(items, list) or not all(isinstance(item, str) and item for item in items): + raise ValueError("skip must be a JSON array of strings") + + return set(items) + + +def load_config(config_path): + try: + with open(config_path) as file: + config = yaml.safe_load(file) + except yaml.YAMLError as error: + raise ValueError("github repo config must be valid YAML") from error + + if not isinstance(config, dict): + raise ValueError("github repo config must be a mapping") + + checks = config.get("checks") + if not isinstance(checks, dict) or not checks: + raise ValueError("github repo config must contain a non-empty checks mapping") + for field in checks: + if not isinstance(field, str) or not FIELD_PATTERN.fullmatch(field): + raise ValueError("github repo check names must be GraphQL field names") + + return checks + + +def github_request(args): + result = subprocess.run(["gh", *args], capture_output=True, text=True) + if result.returncode: + message = result.stderr.strip() or result.stdout.strip() or "unknown error" + raise RuntimeError(message) + + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError("GitHub API returned invalid JSON") from error + + +def repository_name(repository): + parts = repository.split("/") + if len(parts) != 2 or not all(parts): + raise ValueError("repository must use owner/name format") + return parts + + +def github_repository(repository, fields): + owner, name = repository_name(repository) + selection = " ".join(fields) + query = ( + "query($owner: String!, $name: String!) { " + f"repository(owner: $owner, name: $name) {{ {selection} }} " + "}" + ) + response = github_request( + ["api", "graphql", "-f", f"query={query}", "-F", f"owner={owner}", "-F", f"name={name}"] + ) + try: + return response["data"]["repository"] + except (KeyError, TypeError) as error: + raise RuntimeError("GitHub returned an invalid repository response") from error + + +def format_value(value): + return json.dumps(value, separators=(",", ":"), sort_keys=True) + + +def evaluate_checks(checks, repository, skipped=(), request=github_repository): + skipped = set(skipped) + if unknown := skipped - set(checks): + names = ", ".join(sorted(unknown)) + raise ValueError(f"Unknown skipped GitHub config checks: {names}") + + active_fields = [field for field in checks if field not in skipped] + payload = None + request_error = None + if active_fields: + try: + payload = request(repository, active_fields) + except RuntimeError as error: + request_error = str(error) + + results = [] + for field, expected in checks.items(): + if field in skipped: + results.append(CheckResult(field, "skipped", "skipped by workflow input")) + continue + + if request_error: + results.append(CheckResult(field, "failed", f"GitHub request failed: {request_error}")) + continue + + if field not in payload: + results.append(CheckResult(field, "failed", f"GitHub API response has no {field} field")) + continue + actual = payload[field] + + if type(actual) is not type(expected) or actual != expected: + message = f"expected {format_value(expected)}, got {format_value(actual)}" + results.append(CheckResult(field, "failed", message)) + continue + + results.append(CheckResult(field, "passed", format_value(actual))) + + return results + + +def annotation_value(value): + return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def main(): + try: + checks = load_config(Path(os.environ["CONFIG_PATH"])) + skipped = parse_json_list(os.environ.get("SKIP", "[]")) + repository = os.environ["REPOSITORY"] + results = evaluate_checks(checks, repository, skipped) + except (KeyError, OSError, ValueError) as error: + print(f"::error::{annotation_value(error)}") + return 1 + + print(f"GitHub repository config: {repository}") + for result in results: + if result.status == "failed": + title = annotation_value(f"GitHub config: {result.name}") + message = annotation_value(result.message) + print(f"::error title={title}::{message}") + else: + print(f"{result.status.upper()} {result.name}: {result.message}") + + failed = sum(result.status == "failed" for result in results) + passed = sum(result.status == "passed" for result in results) + skipped_count = sum(result.status == "skipped" for result in results) + print(f"Result: {passed} passed, {failed} failed, {skipped_count} skipped") + return int(failed > 0) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/github-shared.yml b/.github/workflows/github-shared.yml new file mode 100644 index 0000000..b12a805 --- /dev/null +++ b/.github/workflows/github-shared.yml @@ -0,0 +1,17 @@ +name: GitHub (shared) +on: + workflow_call: + inputs: + skip: + description: JSON array of GitHub repository config checks to skip. + type: string + default: "[]" +jobs: + github-config-check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: $/.github/actions/check-github-config + with: + skip: ${{ inputs.skip }} diff --git a/.github/workflows/github.yml b/.github/workflows/github.yml new file mode 100644 index 0000000..a160c24 --- /dev/null +++ b/.github/workflows/github.yml @@ -0,0 +1,8 @@ +name: GitHub +on: + push: + branches: ["main"] + pull_request: +jobs: + github-config-check: + uses: ./.github/workflows/github-shared.yml diff --git a/README.md b/README.md index 95f358a..0e784c4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,38 @@ Skipped linters must also be removed from the baseline entry in `.pre-commit-config.yaml` so local and CI linting remain identical. Unknown skip names fail the workflow. -### 2. Pre-commit hooks +### 2. GitHub repository config + +Create `.github/workflows/github.yml`: + +```yaml +name: GitHub +on: + push: + branches: ["main"] + pull_request: +jobs: + github-config-check: + uses: rubykatzen/baseline/.github/workflows/github-shared.yml@VERSION +``` + +The shared workflow checks repository settings against `config/github.yml`. +The initial policy requires the wiki to be disabled, auto-merge to be enabled, +and merged branches to be deleted automatically. + +Skip checks explicitly when a repository needs an exception: + +```yaml +jobs: + github-config-check: + uses: rubykatzen/baseline/.github/workflows/github-shared.yml@VERSION + with: + skip: '["hasWikiEnabled"]' +``` + +The `skip` input must be a JSON array. Unknown check names fail the workflow. + +### 3. Pre-commit hooks Copy `.pre-commit-config.yaml.example` to your repo or add to your existing config. Include only the hooks relevant to your stack: @@ -77,7 +108,7 @@ Ruby hooks use `bundle exec`; install Ruby and run `bundle install` in the consuming repository first. `rubocop` and `erb_lint` must be available through the [`rubykatzen-baseline`](#ruby-gem-rubocop--erb_lint) gem. -### 3. Dependabot +### 4. Dependabot Add `.github/dependabot.yml` to keep GitHub Actions and pre-commit pins current automatically: diff --git a/config/github.yml b/config/github.yml new file mode 100644 index 0000000..455d479 --- /dev/null +++ b/config/github.yml @@ -0,0 +1,4 @@ +checks: + hasWikiEnabled: false + autoMergeAllowed: true + deleteBranchOnMerge: true diff --git a/test/test_github_config.py b/test/test_github_config.py new file mode 100644 index 0000000..f388900 --- /dev/null +++ b/test/test_github_config.py @@ -0,0 +1,108 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +BASELINE_ROOT = Path(__file__).parent.parent +SPEC = importlib.util.spec_from_file_location( + "check_github_config", BASELINE_ROOT / ".github" / "actions" / "check-github-config" / "check.py" +) +CHECK_GITHUB_CONFIG = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECK_GITHUB_CONFIG) + + +class GitHubConfigTest(unittest.TestCase): + def setUp(self): + self.checks = { + "hasWikiEnabled": False, + "autoMergeAllowed": True, + } + + def test_loads_repository_config(self): + checks = CHECK_GITHUB_CONFIG.load_config(BASELINE_ROOT / "config" / "github.yml") + + self.assertEqual(set(checks), {"hasWikiEnabled", "autoMergeAllowed", "deleteBranchOnMerge"}) + + def test_rejects_invalid_repository_config(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml") as config: + config.write("checks:\n invalid-field: false\n") + config.flush() + + with self.assertRaisesRegex(ValueError, "must be GraphQL field names"): + CHECK_GITHUB_CONFIG.load_config(config.name) + + def test_rejects_malformed_yaml(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml") as config: + config.write("checks: [\n") + config.flush() + + with self.assertRaisesRegex(ValueError, "must be valid YAML"): + CHECK_GITHUB_CONFIG.load_config(config.name) + + def test_parses_json_skip(self): + self.assertEqual(CHECK_GITHUB_CONFIG.parse_json_list('["hasWikiEnabled"]'), {"hasWikiEnabled"}) + + def test_rejects_invalid_json_skip(self): + for value in ('"hasWikiEnabled"', "hasWikiEnabled", '["hasWikiEnabled", 1]'): + with self.subTest(value=value), self.assertRaisesRegex(ValueError, "JSON array of strings"): + CHECK_GITHUB_CONFIG.parse_json_list(value) + + def test_rejects_unknown_skip(self): + with self.assertRaisesRegex(ValueError, "Unknown skipped GitHub config checks: typo"): + CHECK_GITHUB_CONFIG.evaluate_checks(self.checks, "owner/repo", {"typo"}) + + def test_evaluates_checks_and_reuses_api_response(self): + requests = [] + + def request(repository, fields): + requests.append((repository, fields)) + return {"hasWikiEnabled": False, "autoMergeAllowed": True} + + results = CHECK_GITHUB_CONFIG.evaluate_checks(self.checks, "owner/repo", request=request) + + self.assertEqual([result.status for result in results], ["passed", "passed"]) + self.assertEqual(requests, [("owner/repo", ["hasWikiEnabled", "autoMergeAllowed"])]) + + def test_skips_check_without_requesting_it(self): + results = CHECK_GITHUB_CONFIG.evaluate_checks( + {"hasWikiEnabled": self.checks["hasWikiEnabled"]}, + "owner/repo", + {"hasWikiEnabled"}, + request=lambda _repository, _fields: self.fail("skipped check made an API request"), + ) + + self.assertEqual(results[0].status, "skipped") + + def test_aggregates_mismatches(self): + results = CHECK_GITHUB_CONFIG.evaluate_checks( + self.checks, + "owner/repo", + request=lambda _repository, _fields: {"hasWikiEnabled": True, "autoMergeAllowed": False}, + ) + + self.assertEqual( + [result.name for result in results if result.status == "failed"], + ["hasWikiEnabled", "autoMergeAllowed"], + ) + + def test_reports_api_failure_for_each_dependent_check(self): + def request(_repository, _fields): + raise RuntimeError("API unavailable") + + results = CHECK_GITHUB_CONFIG.evaluate_checks(self.checks, "owner/repo", request=request) + + self.assertEqual([result.status for result in results], ["failed", "failed"]) + self.assertTrue(all("API unavailable" in result.message for result in results)) + + def test_formats_expected_values_as_json(self): + self.assertEqual(CHECK_GITHUB_CONFIG.format_value(False), "false") + self.assertEqual(json.loads(CHECK_GITHUB_CONFIG.format_value({"enabled": True})), {"enabled": True}) + + def test_rejects_repository_without_owner(self): + with self.assertRaisesRegex(ValueError, "owner/name"): + CHECK_GITHUB_CONFIG.repository_name("repository") + + +if __name__ == "__main__": + unittest.main()