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
10 changes: 10 additions & 0 deletions .github/repo-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"delete_branch_on_merge": true,
"allow_auto_merge": false,
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_rebase_merge": true,
"has_wiki": false,
"has_projects": false,
"web_commit_signoff_required": false
}
43 changes: 43 additions & 0 deletions .github/rulesets/main.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "protect-main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "Render and validate both variants" },
{ "context": "Smoke-test python-ci.yml / Lint, type-check, and test" },
{ "context": "Smoke-test node-ci.yml / Type-check and build" }
]
}
}
]
}
30 changes: 30 additions & 0 deletions .github/workflows/template-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ jobs:
# raising default_language_version is the regression to catch.
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"
done
# Repo settings as code is opt-in: the defaults carry none of it, so a
# `copier update --defaults` cannot push it into downstream repos.
for kind in app library; do
test ! -e "/tmp/out-$kind/scripts"
test ! -e "/tmp/out-$kind/.github/rulesets"
test ! -e "/tmp/out-$kind/.github/repo-settings.json"
done
# The applier is tested against a stubbed gh, here in the template repo
# (the file's path has copier syntax in it, so the test loads it by path).
uvx --with pytest --from pytest pytest -q tests/test_setup_repo.py
uvx copier copy --trust --defaults --vcs-ref=HEAD \
--data project_name="sample-repo-settings" \
--data project_description="A sample managing its repo settings" \
--data use_repo_settings=true \
. /tmp/out-repo-settings
# Apply script plus JSON payloads render intact, and the script keeps
# its executable bit.
test -x /tmp/out-repo-settings/scripts/setup_repo.py
python3 -m py_compile /tmp/out-repo-settings/scripts/setup_repo.py
/tmp/out-repo-settings/scripts/setup_repo.py --help > /dev/null
python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-repo-settings/.github/rulesets/main.json
python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-repo-settings/.github/repo-settings.json
grep -q 'ci / Lint, type-check, and test' /tmp/out-repo-settings/.github/rulesets/main.json
! grep -q 'frontend /' /tmp/out-repo-settings/.github/rulesets/main.json
echo "repo settings opt-in honoured."

# library-only files present for library, absent for app
test -f /tmp/out-library/RELEASING.md
test -f /tmp/out-library/.github/workflows/publish.yml
Expand All @@ -81,12 +107,16 @@ jobs:
--data project_name="sample-frontend" \
--data project_description="A sample with a frontend" \
--data use_frontend=true \
--data use_repo_settings=true \
. /tmp/out-frontend
test -f /tmp/out-frontend/biome.json
grep -q "biomejs/pre-commit" /tmp/out-frontend/.pre-commit-config.yaml
grep -q "node-ci.yml" /tmp/out-frontend/.github/workflows/ci.yml
python3 -c "import json; json.load(open('/tmp/out-frontend/biome.json'))"
python3 -c "import yaml; yaml.safe_load(open('/tmp/out-frontend/.github/workflows/ci.yml'))"
# and its ruleset requires the frontend job too
python3 -c "import json,sys; json.load(open(sys.argv[1]))" /tmp/out-frontend/.github/rulesets/main.json
grep -q 'frontend / Type-check and build' /tmp/out-frontend/.github/rulesets/main.json
# `recommended` is deprecated in Biome 2.5.5; `preset` is the spelling
# that doesn't emit a notice.
grep -q '"preset": "recommended"' /tmp/out-frontend/biome.json
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ Entries for 1.0.0 through 1.5.2 were backfilled from git history after the fact,

## [Unreleased]

### Added

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

### Changed

- Rebranded for the Generality-Labs fork: `github_owner` now defaults to
Expand Down
58 changes: 56 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ uvx copier copy gh:Generality-Labs/python-project-template my-new-project

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

### Turning off `typos`

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

## Repo settings as code

GitHub keeps repository settings and rulesets in the UI and API rather than in
files, so the scaffold can ship the files *and* the thing that applies them.
This is **opt-in**: answer yes to `use_repo_settings` (default no). Nothing
changes on GitHub until someone runs the script, but the default is off so a
`copier update` never drops the files, or the invitation to run them, into a
downstream repo that didn't ask. An existing project opts in by setting
`use_repo_settings: true` in `.copier-answers.yml` and running `copier update`.

- `.github/repo-settings.json` is sent verbatim as the body of
`PATCH /repos/{owner}/{repo}`, so any key [that endpoint
accepts](https://docs.github.com/rest/repos/repos#update-a-repository) can be
managed there: merge methods, `has_wiki` / `has_projects`, and notably
`delete_branch_on_merge: true` (stacked PRs only retarget when merged base
branches are deleted). If a key turns out to be plan-gated for a repo the
whole PATCH 403s; remove the key and re-run.
- `.github/rulesets/*.json` are rulesets in the exact shape the GitHub UI
imports and exports (Settings -> Rules -> Rulesets), so they round-trip
through the dashboard.
- `scripts/setup_repo.py` (standard library only; needs `gh` authenticated
as a repo admin) applies both. It first fetches what the repo has now and
prints the difference: settings keys whose value would change, and a unified
diff of each ruleset against GitHub's copy, projected onto the keys the file
sets so ids, timestamps and GitHub's filled-in defaults never show as
changes. Nothing is applied until you confirm; `--dry-run` only shows,
`--yes` skips the prompt (and is required when not run from a terminal).
Re-runs are safe: unchanged keys are skipped and rulesets are updated in
place by name, never duplicated.

The scaffolded `protect-main` ruleset stops deletion and force-pushes of the
default branch, requires changes to arrive by PR (0 approvals, so a solo
maintainer isn't blocked), and requires the `ci / Lint, type-check, and test`
check (plus `frontend / Type-check and build` when the project has a frontend).
Repository **admins bypass it** (`actor_id: 5` is the built-in Admin role) so a
release commit can still be pushed directly; tighten that as the team grows.
A required check only takes effect once it has run at least once on the repo,
so run CI before you rely on it.

Plan gating: the rulesets API refuses private repos on the Free plan (403). The
script applies the plain settings, says so, and exits 0; re-run it after the
plan changes. Generality-Labs is on Team, so org repos are unaffected.

This repo carries its own copies of both files and applies them with the
scaffolded script, from the repo root:

```bash
python3 'template/{% if use_repo_settings %}scripts{% endif %}/setup_repo.py' Generality-Labs/python-project-template
```

The script is unit-tested against a stubbed `gh` in `tests/test_setup_repo.py`,
which template CI runs.

## Keeping projects up to date

Answer yes to `use_template_update` (the default) and the scaffold gets a
Expand Down
17 changes: 17 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ _message_after_copy: |
uv sync
uv run pre-commit install
git init && git add -A && git commit -m "Initial commit"
{% if use_repo_settings %}

Once the repo exists on GitHub, review and apply its settings and branch
ruleset (shows what would change first; edit .github/rulesets/*.json if needed):
scripts/setup_repo.py
{% endif %}

project_name:
type: str
Expand Down Expand Up @@ -90,6 +96,17 @@ use_template_update:
help: Open a PR automatically when the template changes? (weekly `copier update`)
default: true

use_repo_settings:
type: bool
# Scaffolds .github/repo-settings.json, .github/rulesets/main.json and
# scripts/setup_repo.py, which apply repo settings and a protect-main
# ruleset through the GitHub API when someone runs the script. Off by
# default so `copier update --defaults` never drops the files (and the
# invitation to run them) into a downstream repo that didn't ask; a repo
# opts in by answering yes here or by editing .copier-answers.yml.
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)
default: false

coverage_floor:
type: int
help: Minimum coverage percentage
Expand Down
43 changes: 43 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Ruff settings for this repo's own Python: the scaffolded scripts under
# template/ and the tests that exercise them. Mirrors the [tool.ruff] block in
# template/pyproject.toml.jinja so a file formats identically here and in a
# scaffolded project. Without this, pre-commit in the smoke-test job formats
# these files with ruff's defaults (line length 88) and disagrees with the
# scaffold's 100.
line-length = 100
target-version = "py311"

[lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"D", # pydocstyle
"C4", # flake8-comprehensions
"PT", # flake8-pytest-style
"PIE", # flake8-pie
"DTZ", # flake8-datetimez (timezone-aware datetimes)
"ISC", # implicit-str-concat (catches missing commas)
"ASYNC", # flake8-async
"N", # pep8-naming
"FURB", # refurb (modernization)
"RUF", # ruff-specific rules
"PLE", # pylint errors
"PLW", # pylint warnings
]
ignore = [
"E501", # line-too-long: the formatter owns line length
"D10", # missing docstrings
"D415", # first-line punctuation
"ISC001", # single-line implicit concat conflicts with the formatter
]

[lint.pydocstyle]
convention = "google"

[lint.per-file-ignores]
"tests/**" = ["D"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"delete_branch_on_merge": true,
"allow_auto_merge": false,
"allow_squash_merge": true,
"allow_merge_commit": true,
"allow_rebase_merge": true,
"has_wiki": false,
"has_projects": false,
"web_commit_signoff_required": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "protect-main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
],
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "ci / Lint, type-check, and test" }{% if use_frontend %},
{ "context": "frontend / Type-check and build" }{% endif %}
]
}
}
]
}
Loading