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: 18 additions & 0 deletions .github/actions/check-github-config/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
165 changes: 165 additions & 0 deletions .github/actions/check-github-config/check.py
Original file line number Diff line number Diff line change
@@ -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())
17 changes: 17 additions & 0 deletions .github/workflows/github-shared.yml
Original file line number Diff line number Diff line change
@@ -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 }}
8 changes: 8 additions & 0 deletions .github/workflows/github.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: GitHub
on:
push:
branches: ["main"]
pull_request:
jobs:
github-config-check:
uses: ./.github/workflows/github-shared.yml
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions config/github.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
checks:
hasWikiEnabled: false
autoMergeAllowed: true
deleteBranchOnMerge: true
108 changes: 108 additions & 0 deletions test/test_github_config.py
Original file line number Diff line number Diff line change
@@ -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()