Skip to content

Commit 45cd92d

Browse files
committed
feat: add shared GitHub config checks
1 parent 8c04131 commit 45cd92d

7 files changed

Lines changed: 353 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: check-github-config
2+
description: Check repository settings against the baseline GitHub configuration.
3+
inputs:
4+
skip:
5+
description: JSON array of GitHub repository config checks to skip.
6+
default: "[]"
7+
runs:
8+
using: composite
9+
steps:
10+
- run: python3 -m pip install pyyaml
11+
shell: bash
12+
- run: python3 "${{ github.action_path }}/check.py"
13+
shell: bash
14+
env:
15+
CONFIG_PATH: ${{ github.action_path }}/../../../config/github-repo.yml
16+
GH_TOKEN: ${{ github.token }}
17+
REPOSITORY: ${{ github.repository }}
18+
SKIP: ${{ inputs.skip }}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env python3
2+
import json
3+
import os
4+
import re
5+
import subprocess
6+
import sys
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
10+
import yaml
11+
12+
FIELD_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
13+
14+
15+
@dataclass(frozen=True)
16+
class CheckResult:
17+
name: str
18+
status: str
19+
message: str
20+
21+
22+
def parse_json_list(value):
23+
try:
24+
items = json.loads(value)
25+
except json.JSONDecodeError as error:
26+
raise ValueError("skip must be a JSON array of strings") from error
27+
28+
if not isinstance(items, list) or not all(isinstance(item, str) and item for item in items):
29+
raise ValueError("skip must be a JSON array of strings")
30+
31+
return set(items)
32+
33+
34+
def load_config(config_path):
35+
try:
36+
with open(config_path) as file:
37+
config = yaml.safe_load(file)
38+
except yaml.YAMLError as error:
39+
raise ValueError("github repo config must be valid YAML") from error
40+
41+
if not isinstance(config, dict):
42+
raise ValueError("github repo config must be a mapping")
43+
44+
checks = config.get("checks")
45+
if not isinstance(checks, dict) or not checks:
46+
raise ValueError("github repo config must contain a non-empty checks mapping")
47+
for field in checks:
48+
if not isinstance(field, str) or not FIELD_PATTERN.fullmatch(field):
49+
raise ValueError("github repo check names must be GraphQL field names")
50+
51+
return checks
52+
53+
54+
def github_request(args):
55+
result = subprocess.run(["gh", *args], capture_output=True, text=True)
56+
if result.returncode:
57+
message = result.stderr.strip() or result.stdout.strip() or "unknown error"
58+
raise RuntimeError(message)
59+
60+
try:
61+
return json.loads(result.stdout)
62+
except json.JSONDecodeError as error:
63+
raise RuntimeError("GitHub API returned invalid JSON") from error
64+
65+
66+
def repository_name(repository):
67+
parts = repository.split("/")
68+
if len(parts) != 2 or not all(parts):
69+
raise ValueError("repository must use owner/name format")
70+
return parts
71+
72+
73+
def github_repository(repository, fields):
74+
owner, name = repository_name(repository)
75+
selection = " ".join(fields)
76+
query = (
77+
"query($owner: String!, $name: String!) { "
78+
f"repository(owner: $owner, name: $name) {{ {selection} }} "
79+
"}"
80+
)
81+
response = github_request(
82+
["api", "graphql", "-f", f"query={query}", "-F", f"owner={owner}", "-F", f"name={name}"]
83+
)
84+
try:
85+
return response["data"]["repository"]
86+
except (KeyError, TypeError) as error:
87+
raise RuntimeError("GitHub returned an invalid repository response") from error
88+
89+
90+
def format_value(value):
91+
return json.dumps(value, separators=(",", ":"), sort_keys=True)
92+
93+
94+
def evaluate_checks(checks, repository, skipped=(), request=github_repository):
95+
skipped = set(skipped)
96+
if unknown := skipped - set(checks):
97+
names = ", ".join(sorted(unknown))
98+
raise ValueError(f"Unknown skipped GitHub config checks: {names}")
99+
100+
active_fields = [field for field in checks if field not in skipped]
101+
payload = None
102+
request_error = None
103+
if active_fields:
104+
try:
105+
payload = request(repository, active_fields)
106+
except RuntimeError as error:
107+
request_error = str(error)
108+
109+
results = []
110+
for field, expected in checks.items():
111+
if field in skipped:
112+
results.append(CheckResult(field, "skipped", "skipped by workflow input"))
113+
continue
114+
115+
if request_error:
116+
results.append(CheckResult(field, "failed", f"GitHub request failed: {request_error}"))
117+
continue
118+
119+
if field not in payload:
120+
results.append(CheckResult(field, "failed", f"GitHub API response has no {field} field"))
121+
continue
122+
actual = payload[field]
123+
124+
if type(actual) is not type(expected) or actual != expected:
125+
message = f"expected {format_value(expected)}, got {format_value(actual)}"
126+
results.append(CheckResult(field, "failed", message))
127+
continue
128+
129+
results.append(CheckResult(field, "passed", format_value(actual)))
130+
131+
return results
132+
133+
134+
def annotation_value(value):
135+
return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
136+
137+
138+
def main():
139+
try:
140+
checks = load_config(Path(os.environ["CONFIG_PATH"]))
141+
skipped = parse_json_list(os.environ.get("SKIP", "[]"))
142+
repository = os.environ["REPOSITORY"]
143+
results = evaluate_checks(checks, repository, skipped)
144+
except (KeyError, OSError, ValueError) as error:
145+
print(f"::error::{annotation_value(error)}")
146+
return 1
147+
148+
print(f"GitHub repository config: {repository}")
149+
for result in results:
150+
if result.status == "failed":
151+
title = annotation_value(f"GitHub config: {result.name}")
152+
message = annotation_value(result.message)
153+
print(f"::error title={title}::{message}")
154+
else:
155+
print(f"{result.status.upper()} {result.name}: {result.message}")
156+
157+
failed = sum(result.status == "failed" for result in results)
158+
passed = sum(result.status == "passed" for result in results)
159+
skipped_count = sum(result.status == "skipped" for result in results)
160+
print(f"Result: {passed} passed, {failed} failed, {skipped_count} skipped")
161+
return int(failed > 0)
162+
163+
164+
if __name__ == "__main__":
165+
sys.exit(main())
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: GitHub config check (shared)
2+
on:
3+
workflow_call:
4+
inputs:
5+
skip:
6+
description: JSON array of GitHub repository config checks to skip.
7+
type: string
8+
default: "[]"
9+
jobs:
10+
github-config-check:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
steps:
15+
- uses: $/.github/actions/check-github-config
16+
with:
17+
skip: ${{ inputs.skip }}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
name: GitHub config check
2+
on:
3+
push:
4+
branches: ["main"]
5+
pull_request:
6+
jobs:
7+
github-config-check:
8+
uses: ./.github/workflows/github-config-check-shared.yml

‎README.md‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,38 @@ Skipped linters must also be removed from the baseline entry in
4545
`.pre-commit-config.yaml` so local and CI linting remain identical. Unknown
4646
skip names fail the workflow.
4747

48-
### 2. Pre-commit hooks
48+
### 2. GitHub repository config
49+
50+
Create `.github/workflows/github-config-check.yml`:
51+
52+
```yaml
53+
name: GitHub config check
54+
on:
55+
push:
56+
branches: ["main"]
57+
pull_request:
58+
jobs:
59+
github-config-check:
60+
uses: rubykatzen/baseline/.github/workflows/github-config-check-shared.yml@VERSION
61+
```
62+
63+
The shared workflow checks repository settings against `config/github-repo.yml`.
64+
The initial policy requires the wiki to be disabled, auto-merge to be enabled,
65+
and merged branches to be deleted automatically.
66+
67+
Skip checks explicitly when a repository needs an exception:
68+
69+
```yaml
70+
jobs:
71+
github-config-check:
72+
uses: rubykatzen/baseline/.github/workflows/github-config-check-shared.yml@VERSION
73+
with:
74+
skip: '["hasWikiEnabled"]'
75+
```
76+
77+
The `skip` input must be a JSON array. Unknown check names fail the workflow.
78+
79+
### 3. Pre-commit hooks
4980

5081
Copy `.pre-commit-config.yaml.example` to your repo or add to your existing config.
5182
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
77108
consuming repository first. `rubocop` and `erb_lint` must be available through
78109
the [`rubykatzen-baseline`](#ruby-gem-rubocop--erb_lint) gem.
79110

80-
### 3. Dependabot
111+
### 4. Dependabot
81112

82113
Add `.github/dependabot.yml` to keep GitHub Actions and pre-commit pins
83114
current automatically:

‎config/github-repo.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
checks:
2+
hasWikiEnabled: false
3+
autoMergeAllowed: true
4+
deleteBranchOnMerge: true

‎test/test_github_config.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import importlib.util
2+
import json
3+
import tempfile
4+
import unittest
5+
from pathlib import Path
6+
7+
BASELINE_ROOT = Path(__file__).parent.parent
8+
SPEC = importlib.util.spec_from_file_location(
9+
"check_github_config", BASELINE_ROOT / ".github" / "actions" / "check-github-config" / "check.py"
10+
)
11+
CHECK_GITHUB_CONFIG = importlib.util.module_from_spec(SPEC)
12+
SPEC.loader.exec_module(CHECK_GITHUB_CONFIG)
13+
14+
15+
class GitHubConfigTest(unittest.TestCase):
16+
def setUp(self):
17+
self.checks = {
18+
"hasWikiEnabled": False,
19+
"autoMergeAllowed": True,
20+
}
21+
22+
def test_loads_repository_config(self):
23+
checks = CHECK_GITHUB_CONFIG.load_config(BASELINE_ROOT / "config" / "github-repo.yml")
24+
25+
self.assertEqual(set(checks), {"hasWikiEnabled", "autoMergeAllowed", "deleteBranchOnMerge"})
26+
27+
def test_rejects_invalid_repository_config(self):
28+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml") as config:
29+
config.write("checks:\n invalid-field: false\n")
30+
config.flush()
31+
32+
with self.assertRaisesRegex(ValueError, "must be GraphQL field names"):
33+
CHECK_GITHUB_CONFIG.load_config(config.name)
34+
35+
def test_rejects_malformed_yaml(self):
36+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml") as config:
37+
config.write("checks: [\n")
38+
config.flush()
39+
40+
with self.assertRaisesRegex(ValueError, "must be valid YAML"):
41+
CHECK_GITHUB_CONFIG.load_config(config.name)
42+
43+
def test_parses_json_skip(self):
44+
self.assertEqual(CHECK_GITHUB_CONFIG.parse_json_list('["hasWikiEnabled"]'), {"hasWikiEnabled"})
45+
46+
def test_rejects_invalid_json_skip(self):
47+
for value in ('"hasWikiEnabled"', "hasWikiEnabled", '["hasWikiEnabled", 1]'):
48+
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "JSON array of strings"):
49+
CHECK_GITHUB_CONFIG.parse_json_list(value)
50+
51+
def test_rejects_unknown_skip(self):
52+
with self.assertRaisesRegex(ValueError, "Unknown skipped GitHub config checks: typo"):
53+
CHECK_GITHUB_CONFIG.evaluate_checks(self.checks, "owner/repo", {"typo"})
54+
55+
def test_evaluates_checks_and_reuses_api_response(self):
56+
requests = []
57+
58+
def request(repository, fields):
59+
requests.append((repository, fields))
60+
return {"hasWikiEnabled": False, "autoMergeAllowed": True}
61+
62+
results = CHECK_GITHUB_CONFIG.evaluate_checks(self.checks, "owner/repo", request=request)
63+
64+
self.assertEqual([result.status for result in results], ["passed", "passed"])
65+
self.assertEqual(requests, [("owner/repo", ["hasWikiEnabled", "autoMergeAllowed"])])
66+
67+
def test_skips_check_without_requesting_it(self):
68+
results = CHECK_GITHUB_CONFIG.evaluate_checks(
69+
{"hasWikiEnabled": self.checks["hasWikiEnabled"]},
70+
"owner/repo",
71+
{"hasWikiEnabled"},
72+
request=lambda _repository, _fields: self.fail("skipped check made an API request"),
73+
)
74+
75+
self.assertEqual(results[0].status, "skipped")
76+
77+
def test_aggregates_mismatches(self):
78+
results = CHECK_GITHUB_CONFIG.evaluate_checks(
79+
self.checks,
80+
"owner/repo",
81+
request=lambda _repository, _fields: {"hasWikiEnabled": True, "autoMergeAllowed": False},
82+
)
83+
84+
self.assertEqual(
85+
[result.name for result in results if result.status == "failed"],
86+
["hasWikiEnabled", "autoMergeAllowed"],
87+
)
88+
89+
def test_reports_api_failure_for_each_dependent_check(self):
90+
def request(_repository, _fields):
91+
raise RuntimeError("API unavailable")
92+
93+
results = CHECK_GITHUB_CONFIG.evaluate_checks(self.checks, "owner/repo", request=request)
94+
95+
self.assertEqual([result.status for result in results], ["failed", "failed"])
96+
self.assertTrue(all("API unavailable" in result.message for result in results))
97+
98+
def test_formats_expected_values_as_json(self):
99+
self.assertEqual(CHECK_GITHUB_CONFIG.format_value(False), "false")
100+
self.assertEqual(json.loads(CHECK_GITHUB_CONFIG.format_value({"enabled": True})), {"enabled": True})
101+
102+
def test_rejects_repository_without_owner(self):
103+
with self.assertRaisesRegex(ValueError, "owner/name"):
104+
CHECK_GITHUB_CONFIG.repository_name("repository")
105+
106+
107+
if __name__ == "__main__":
108+
unittest.main()

0 commit comments

Comments
 (0)