Skip to content

Commit 012d7b4

Browse files
authored
Merge pull request #2 from Generality-Labs/feat/repo-settings
Repo settings as code: rulesets, settings JSON and setup-repo.py
2 parents 08fa8d3 + 78646d1 commit 012d7b4

11 files changed

Lines changed: 811 additions & 2 deletions

File tree

‎.github/repo-settings.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"delete_branch_on_merge": true,
3+
"allow_auto_merge": false,
4+
"allow_squash_merge": true,
5+
"allow_merge_commit": true,
6+
"allow_rebase_merge": true,
7+
"has_wiki": false,
8+
"has_projects": false,
9+
"web_commit_signoff_required": false
10+
}

‎.github/rulesets/main.json‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "protect-main",
3+
"target": "branch",
4+
"enforcement": "active",
5+
"conditions": {
6+
"ref_name": {
7+
"include": ["~DEFAULT_BRANCH"],
8+
"exclude": []
9+
}
10+
},
11+
"bypass_actors": [
12+
{
13+
"actor_id": 5,
14+
"actor_type": "RepositoryRole",
15+
"bypass_mode": "always"
16+
}
17+
],
18+
"rules": [
19+
{ "type": "deletion" },
20+
{ "type": "non_fast_forward" },
21+
{
22+
"type": "pull_request",
23+
"parameters": {
24+
"required_approving_review_count": 0,
25+
"dismiss_stale_reviews_on_push": false,
26+
"require_code_owner_review": false,
27+
"require_last_push_approval": false,
28+
"required_review_thread_resolution": false
29+
}
30+
},
31+
{
32+
"type": "required_status_checks",
33+
"parameters": {
34+
"strict_required_status_checks_policy": false,
35+
"required_status_checks": [
36+
{ "context": "Render and validate both variants" },
37+
{ "context": "Smoke-test python-ci.yml / Lint, type-check, and test" },
38+
{ "context": "Smoke-test node-ci.yml / Type-check and build" }
39+
]
40+
}
41+
}
42+
]
43+
}

‎.github/workflows/template-ci.yml‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,32 @@ jobs:
5858
# raising default_language_version is the regression to catch.
5959
python3 -c "import sys,yaml; cfg=yaml.safe_load(open(sys.argv[1])); hs=[h for r in cfg['repos'] if 'shellcheck-py' in r['repo'] for h in r['hooks'] if h['id']=='shellcheck']; assert len(hs)==1, hs; v=hs[0].get('language_version'); assert v=='python3.12', f'shellcheck language_version is {v!r}'" "/tmp/out-$kind/.pre-commit-config.yaml"
6060
done
61+
# Repo settings as code is opt-in: the defaults carry none of it, so a
62+
# `copier update --defaults` cannot push it into downstream repos.
63+
for kind in app library; do
64+
test ! -e "/tmp/out-$kind/scripts"
65+
test ! -e "/tmp/out-$kind/.github/rulesets"
66+
test ! -e "/tmp/out-$kind/.github/repo-settings.json"
67+
done
68+
# The applier is tested against a stubbed gh, here in the template repo
69+
# (the file's path has copier syntax in it, so the test loads it by path).
70+
uvx --with pytest --from pytest pytest -q tests/test_setup_repo.py
71+
uvx copier copy --trust --defaults --vcs-ref=HEAD \
72+
--data project_name="sample-repo-settings" \
73+
--data project_description="A sample managing its repo settings" \
74+
--data use_repo_settings=true \
75+
. /tmp/out-repo-settings
76+
# Apply script plus JSON payloads render intact, and the script keeps
77+
# its executable bit.
78+
test -x /tmp/out-repo-settings/scripts/setup_repo.py
79+
python3 -m py_compile /tmp/out-repo-settings/scripts/setup_repo.py
80+
/tmp/out-repo-settings/scripts/setup_repo.py --help > /dev/null
81+
python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-repo-settings/.github/rulesets/main.json
82+
python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-repo-settings/.github/repo-settings.json
83+
grep -q 'ci / Lint, type-check, and test' /tmp/out-repo-settings/.github/rulesets/main.json
84+
! grep -q 'frontend /' /tmp/out-repo-settings/.github/rulesets/main.json
85+
echo "repo settings opt-in honoured."
86+
6187
# library-only files present for library, absent for app
6288
test -f /tmp/out-library/RELEASING.md
6389
test -f /tmp/out-library/.github/workflows/publish.yml
@@ -81,12 +107,16 @@ jobs:
81107
--data project_name="sample-frontend" \
82108
--data project_description="A sample with a frontend" \
83109
--data use_frontend=true \
110+
--data use_repo_settings=true \
84111
. /tmp/out-frontend
85112
test -f /tmp/out-frontend/biome.json
86113
grep -q "biomejs/pre-commit" /tmp/out-frontend/.pre-commit-config.yaml
87114
grep -q "node-ci.yml" /tmp/out-frontend/.github/workflows/ci.yml
88115
python3 -c "import json; json.load(open('/tmp/out-frontend/biome.json'))"
89116
python3 -c "import yaml; yaml.safe_load(open('/tmp/out-frontend/.github/workflows/ci.yml'))"
117+
# and its ruleset requires the frontend job too
118+
python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-frontend/.github/rulesets/main.json
119+
grep -q 'frontend / Type-check and build' /tmp/out-frontend/.github/rulesets/main.json
90120
# `recommended` is deprecated in Biome 2.5.5; `preset` is the spelling
91121
# that doesn't emit a notice.
92122
grep -q '"preset": "recommended"' /tmp/out-frontend/biome.json

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact,
1414

1515
## [Unreleased]
1616

17+
### Added
18+
19+
- Repo settings as code, opt-in via `use_repo_settings` (default off, so
20+
`copier update --defaults` leaves existing projects alone): the scaffold
21+
ships `.github/repo-settings.json`
22+
(the literal `PATCH /repos/{owner}/{repo}` body: merge methods,
23+
`delete_branch_on_merge: true` so stacked PRs retarget, wiki/projects off)
24+
and `.github/rulesets/main.json` (protect the default branch: PRs required,
25+
no force-pushes or deletion, the `ci / Lint, type-check, and test` check
26+
required, plus the frontend check when there is one; repository Admins
27+
bypass), and `scripts/setup_repo.py`, which fetches the repo's current
28+
settings and rulesets, prints what would change (a unified diff per ruleset,
29+
projected onto the keys the file sets), and applies only after confirmation
30+
or `--yes`; `--dry-run` only shows. The post-copy message points at it, and
31+
this repo carries and applies its own copies.
32+
1733
### Changed
1834

1935
- Rebranded for the Generality-Labs fork: `github_owner` now defaults to

‎README.md‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ uvx copier copy gh:Generality-Labs/python-project-template my-new-project
3131

3232
You'll be asked for the name, description, whether it's an app or a library,
3333
Python version, whether to enable a coverage gate, whether the project has a
34-
TypeScript/JavaScript frontend, whether to run the typos spell-checker, and
35-
whether to open template-update PRs automatically.
34+
TypeScript/JavaScript frontend, whether to run the typos spell-checker, whether to
35+
open template-update PRs automatically, and whether to manage repo settings
36+
and a branch ruleset from files (off by default).
3637

3738
### Turning off `typos`
3839

@@ -136,6 +137,59 @@ change between releases. Turn them on per-project when you want them, and keep
136137
`tsc --noEmit` under `strict` as the backstop either way — Biome's inference is
137138
newer and less complete than a full type-checker's.
138139

140+
## Repo settings as code
141+
142+
GitHub keeps repository settings and rulesets in the UI and API rather than in
143+
files, so the scaffold can ship the files *and* the thing that applies them.
144+
This is **opt-in**: answer yes to `use_repo_settings` (default no). Nothing
145+
changes on GitHub until someone runs the script, but the default is off so a
146+
`copier update` never drops the files, or the invitation to run them, into a
147+
downstream repo that didn't ask. An existing project opts in by setting
148+
`use_repo_settings: true` in `.copier-answers.yml` and running `copier update`.
149+
150+
- `.github/repo-settings.json` is sent verbatim as the body of
151+
`PATCH /repos/{owner}/{repo}`, so any key [that endpoint
152+
accepts](https://docs.github.com/rest/repos/repos#update-a-repository) can be
153+
managed there: merge methods, `has_wiki` / `has_projects`, and notably
154+
`delete_branch_on_merge: true` (stacked PRs only retarget when merged base
155+
branches are deleted). If a key turns out to be plan-gated for a repo the
156+
whole PATCH 403s; remove the key and re-run.
157+
- `.github/rulesets/*.json` are rulesets in the exact shape the GitHub UI
158+
imports and exports (Settings -> Rules -> Rulesets), so they round-trip
159+
through the dashboard.
160+
- `scripts/setup_repo.py` (standard library only; needs `gh` authenticated
161+
as a repo admin) applies both. It first fetches what the repo has now and
162+
prints the difference: settings keys whose value would change, and a unified
163+
diff of each ruleset against GitHub's copy, projected onto the keys the file
164+
sets so ids, timestamps and GitHub's filled-in defaults never show as
165+
changes. Nothing is applied until you confirm; `--dry-run` only shows,
166+
`--yes` skips the prompt (and is required when not run from a terminal).
167+
Re-runs are safe: unchanged keys are skipped and rulesets are updated in
168+
place by name, never duplicated.
169+
170+
The scaffolded `protect-main` ruleset stops deletion and force-pushes of the
171+
default branch, requires changes to arrive by PR (0 approvals, so a solo
172+
maintainer isn't blocked), and requires the `ci / Lint, type-check, and test`
173+
check (plus `frontend / Type-check and build` when the project has a frontend).
174+
Repository **admins bypass it** (`actor_id: 5` is the built-in Admin role) so a
175+
release commit can still be pushed directly; tighten that as the team grows.
176+
A required check only takes effect once it has run at least once on the repo,
177+
so run CI before you rely on it.
178+
179+
Plan gating: the rulesets API refuses private repos on the Free plan (403). The
180+
script applies the plain settings, says so, and exits 0; re-run it after the
181+
plan changes. Generality-Labs is on Team, so org repos are unaffected.
182+
183+
This repo carries its own copies of both files and applies them with the
184+
scaffolded script, from the repo root:
185+
186+
```bash
187+
python3 'template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py' Generality-Labs/python-project-template
188+
```
189+
190+
The script is unit-tested against a stubbed `gh` in `tests/test_setup_repo.py`,
191+
which template CI runs.
192+
139193
## Keeping projects up to date
140194

141195
Answer yes to `use_template_update` (the default) and the scaffold gets a

‎copier.yml‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ _message_after_copy: |
1616
uv sync
1717
uv run pre-commit install
1818
git init && git add -A && git commit -m "Initial commit"
19+
{% if use_repo_settings %}
20+
21+
Once the repo exists on GitHub, review and apply its settings and branch
22+
ruleset (shows what would change first; edit .github/rulesets/*.json if needed):
23+
scripts/setup_repo.py
24+
{% endif %}
1925
2026
project_name:
2127
type: str
@@ -90,6 +96,17 @@ use_template_update:
9096
help: Open a PR automatically when the template changes? (weekly `copier update`)
9197
default: true
9298

99+
use_repo_settings:
100+
type: bool
101+
# Scaffolds .github/repo-settings.json, .github/rulesets/main.json and
102+
# scripts/setup_repo.py, which apply repo settings and a protect-main
103+
# ruleset through the GitHub API when someone runs the script. Off by
104+
# default so `copier update --defaults` never drops the files (and the
105+
# invitation to run them) into a downstream repo that didn't ask; a repo
106+
# opts in by answering yes here or by editing .copier-answers.yml.
107+
help: Manage GitHub repo settings and a protect-main ruleset from files in the repo? (adds scripts/setup_repo.py; nothing is applied until you run it)
108+
default: false
109+
93110
coverage_floor:
94111
type: int
95112
help: Minimum coverage percentage

‎ruff.toml‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Ruff settings for this repo's own Python: the scaffolded scripts under
2+
# template/ and the tests that exercise them. Mirrors the [tool.ruff] block in
3+
# template/pyproject.toml.jinja so a file formats identically here and in a
4+
# scaffolded project. Without this, pre-commit in the smoke-test job formats
5+
# these files with ruff's defaults (line length 88) and disagrees with the
6+
# scaffold's 100.
7+
line-length = 100
8+
target-version = "py311"
9+
10+
[lint]
11+
select = [
12+
"E", # pycodestyle errors
13+
"W", # pycodestyle warnings
14+
"F", # pyflakes
15+
"I", # isort
16+
"UP", # pyupgrade
17+
"B", # flake8-bugbear
18+
"SIM", # flake8-simplify
19+
"D", # pydocstyle
20+
"C4", # flake8-comprehensions
21+
"PT", # flake8-pytest-style
22+
"PIE", # flake8-pie
23+
"DTZ", # flake8-datetimez (timezone-aware datetimes)
24+
"ISC", # implicit-str-concat (catches missing commas)
25+
"ASYNC", # flake8-async
26+
"N", # pep8-naming
27+
"FURB", # refurb (modernization)
28+
"RUF", # ruff-specific rules
29+
"PLE", # pylint errors
30+
"PLW", # pylint warnings
31+
]
32+
ignore = [
33+
"E501", # line-too-long: the formatter owns line length
34+
"D10", # missing docstrings
35+
"D415", # first-line punctuation
36+
"ISC001", # single-line implicit concat conflicts with the formatter
37+
]
38+
39+
[lint.pydocstyle]
40+
convention = "google"
41+
42+
[lint.per-file-ignores]
43+
"tests/**" = ["D"]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"delete_branch_on_merge": true,
3+
"allow_auto_merge": false,
4+
"allow_squash_merge": true,
5+
"allow_merge_commit": true,
6+
"allow_rebase_merge": true,
7+
"has_wiki": false,
8+
"has_projects": false,
9+
"web_commit_signoff_required": false
10+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "protect-main",
3+
"target": "branch",
4+
"enforcement": "active",
5+
"conditions": {
6+
"ref_name": {
7+
"include": ["~DEFAULT_BRANCH"],
8+
"exclude": []
9+
}
10+
},
11+
"bypass_actors": [
12+
{
13+
"actor_id": 5,
14+
"actor_type": "RepositoryRole",
15+
"bypass_mode": "always"
16+
}
17+
],
18+
"rules": [
19+
{ "type": "deletion" },
20+
{ "type": "non_fast_forward" },
21+
{
22+
"type": "pull_request",
23+
"parameters": {
24+
"required_approving_review_count": 0,
25+
"dismiss_stale_reviews_on_push": false,
26+
"require_code_owner_review": false,
27+
"require_last_push_approval": false,
28+
"required_review_thread_resolution": false
29+
}
30+
},
31+
{
32+
"type": "required_status_checks",
33+
"parameters": {
34+
"strict_required_status_checks_policy": false,
35+
"required_status_checks": [
36+
{ "context": "ci / Lint, type-check, and test" }{% if use_frontend %},
37+
{ "context": "frontend / Type-check and build" }{% endif %}
38+
]
39+
}
40+
}
41+
]
42+
}

0 commit comments

Comments
 (0)