Skip to content

Commit 2efdc40

Browse files
committed
feature(template): bootstrap new repositories from GitHub metadata
1 parent 15b2b76 commit 2efdc40

File tree

4 files changed

+177
-13
lines changed

4 files changed

+177
-13
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Bootstrap Template
2+
3+
on:
4+
push:
5+
6+
permissions:
7+
contents: write
8+
9+
jobs:
10+
bootstrap:
11+
if: github.actor != 'github-actions[bot]'
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v5
15+
with:
16+
fetch-depth: 0
17+
- name: Set up Python
18+
uses: actions/setup-python@v6
19+
with:
20+
python-version: '3.13'
21+
- name: Bootstrap repository metadata
22+
run: python scripts/bootstrap_template.py --from-github
23+
env:
24+
GITHUB_REPOSITORY: ${{ github.repository }}
25+
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
26+
GITHUB_ACTOR: ${{ github.actor }}
27+
GITHUB_ACTOR_ID: ${{ github.actor_id }}
28+
- name: Commit bootstrap changes
29+
run: |
30+
if git diff --quiet; then
31+
exit 0
32+
fi
33+
git config user.name "github-actions[bot]"
34+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
35+
git add .
36+
git commit -m "chore(template): bootstrap repository metadata"
37+
git push

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ python scripts/bootstrap_template.py your-repository-name
1212

1313
This renames the placeholder package directory, updates the package metadata, and rewrites the main `project_name` and `python-template` references across the repository. Use `--package-name`, `--project-title`, `--author`, `--author-email`, or `--description` if the defaults inferred from the repository name are not enough.
1414

15+
When the repository is created on GitHub from `Use this template`, the
16+
`Bootstrap Template` workflow also runs automatically on the first push and
17+
uses GitHub metadata to rename the repository placeholders. It derives the
18+
repository name from `github.repository`, the author from
19+
`github.repository_owner`, and the author email from the GitHub noreply
20+
address for the triggering actor.
21+
1522
## Getting started
1623

1724
- <code>Use this template > Create a new repository</code> : You can clone this template from the UI by clicking on the upper left repository button.

scripts/bootstrap_template.py

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
import argparse
7+
import os
78
import re
89
from pathlib import Path
910

@@ -38,8 +39,10 @@ def parse_args() -> argparse.Namespace:
3839
parser.add_argument(
3940
"repository_name",
4041
nargs="?",
41-
default=Path.cwd().name,
42-
help="Repository/distribution name, defaults to the current directory.",
42+
help=(
43+
"Repository/distribution name. Defaults to GitHub metadata "
44+
"or the current directory."
45+
),
4346
)
4447
parser.add_argument(
4548
"--package-name",
@@ -70,6 +73,14 @@ def parse_args() -> argparse.Namespace:
7073
default=PLACEHOLDER_DESCRIPTION,
7174
help="Package description to write into project metadata.",
7275
)
76+
parser.add_argument(
77+
"--from-github",
78+
action="store_true",
79+
help=(
80+
"Infer repository metadata from GitHub Actions environment "
81+
"variables."
82+
),
83+
)
7384
parser.add_argument(
7485
"--dry-run",
7586
action="store_true",
@@ -78,6 +89,51 @@ def parse_args() -> argparse.Namespace:
7889
return parser.parse_args()
7990

8091

92+
def github_noreply_email() -> str:
93+
"""Build a GitHub noreply email address from available environment data."""
94+
actor = os.environ.get("GITHUB_ACTOR", "").strip()
95+
actor_id = os.environ.get("GITHUB_ACTOR_ID", "").strip()
96+
if actor and actor_id:
97+
return f"{actor_id}+{actor}@users.noreply.github.com"
98+
if actor:
99+
return f"{actor}@users.noreply.github.com"
100+
return PLACEHOLDER_AUTHOR_EMAIL
101+
102+
103+
def resolve_metadata(args: argparse.Namespace) -> dict[str, str]:
104+
"""Resolve bootstrap metadata from args and GitHub context."""
105+
repository_name = (
106+
args.repository_name or os.environ.get("GITHUB_REPOSITORY", "")
107+
).strip()
108+
if "/" in repository_name:
109+
repository_name = repository_name.rsplit("/", maxsplit=1)[-1]
110+
if not repository_name:
111+
repository_name = Path.cwd().name
112+
113+
package_name = (
114+
args.package_name or repository_name.replace("-", "_")
115+
).strip()
116+
project_title = (
117+
args.project_title
118+
or repository_name.replace("-", " ").replace("_", " ").title()
119+
).strip()
120+
121+
author = args.author
122+
author_email = args.author_email
123+
if args.from_github:
124+
author = os.environ.get("GITHUB_REPOSITORY_OWNER", "").strip() or author
125+
author_email = github_noreply_email()
126+
127+
return {
128+
"repository_name": repository_name,
129+
"package_name": package_name,
130+
"project_title": project_title,
131+
"author": author,
132+
"author_email": author_email,
133+
"description": args.description,
134+
}
135+
136+
81137
def replace_text(
82138
path: Path,
83139
replacements: dict[str, str],
@@ -205,14 +261,10 @@ def rename_package_dir(package_name: str, *, dry_run: bool) -> None:
205261
def main() -> int:
206262
"""Run the template bootstrap process."""
207263
args = parse_args()
208-
repository_name = args.repository_name.strip()
209-
package_name = (
210-
args.package_name or repository_name.replace("-", "_")
211-
).strip()
212-
project_title = (
213-
args.project_title
214-
or repository_name.replace("-", " ").replace("_", " ").title()
215-
).strip()
264+
metadata = resolve_metadata(args)
265+
repository_name = metadata["repository_name"]
266+
package_name = metadata["package_name"]
267+
project_title = metadata["project_title"]
216268

217269
if not repository_name:
218270
raise ValueError("repository_name must not be empty.")
@@ -255,9 +307,9 @@ def main() -> int:
255307
Path("pyproject.toml"),
256308
repository_name=repository_name,
257309
package_name=package_name,
258-
author=args.author,
259-
author_email=args.author_email,
260-
description=args.description,
310+
author=metadata["author"],
311+
author_email=metadata["author_email"],
312+
description=metadata["description"],
261313
dry_run=args.dry_run,
262314
)
263315
rename_package_dir(package_name, dry_run=args.dry_run)

tests/test_bootstrap_template.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,71 @@ def test_bootstrap_template_uses_custom_package_name(tmp_path: Path) -> None:
171171
assert "::: custom_pkg" in (repo_dir / "docs" / "api.md").read_text(
172172
encoding="utf-8"
173173
)
174+
175+
176+
def test_bootstrap_template_uses_github_metadata(tmp_path: Path) -> None:
177+
repo_dir = tmp_path / "placeholder"
178+
repo_dir.mkdir()
179+
(repo_dir / ".devcontainer").mkdir()
180+
(repo_dir / "docs").mkdir()
181+
(repo_dir / "project_name").mkdir()
182+
183+
(repo_dir / ".devcontainer" / "devcontainer.json").write_text(
184+
'{"workspaceFolder": "/workspaces/python-template"}\n',
185+
encoding="utf-8",
186+
)
187+
(repo_dir / "README.md").write_text("# Python Template\n", encoding="utf-8")
188+
(repo_dir / "docs" / "index.md").write_text(
189+
"# project_name\n", encoding="utf-8"
190+
)
191+
(repo_dir / "docs" / "api.md").write_text(
192+
"::: project_name\n", encoding="utf-8"
193+
)
194+
(repo_dir / "mkdocs.yml").write_text(
195+
"site_name: project_name\nrepo_name: python-template\n",
196+
encoding="utf-8",
197+
)
198+
(repo_dir / "pyproject.toml").write_text(
199+
"[project]\n"
200+
'name = "project_name"\n'
201+
'description = "A simple template project."\n'
202+
'authors = [{ name = "Mario Potato", '
203+
'email = "mario.potato@univr.it" }]\n'
204+
"\n"
205+
"[tool.ruff.lint.isort]\n"
206+
'known-first-party = ["project_name"]\n',
207+
encoding="utf-8",
208+
)
209+
210+
env = {
211+
"GITHUB_REPOSITORY": "octo-org/demo-service",
212+
"GITHUB_REPOSITORY_OWNER": "octo-org",
213+
"GITHUB_ACTOR": "octocat",
214+
"GITHUB_ACTOR_ID": "12345",
215+
}
216+
217+
subprocess.run(
218+
[
219+
sys.executable,
220+
str(
221+
Path(
222+
"/home/sebastiano/python-template/scripts/bootstrap_template.py"
223+
)
224+
),
225+
"--from-github",
226+
],
227+
cwd=repo_dir,
228+
check=True,
229+
env=env,
230+
)
231+
232+
assert (repo_dir / "demo_service").exists()
233+
assert 'name = "demo-service"' in (repo_dir / "pyproject.toml").read_text(
234+
encoding="utf-8"
235+
)
236+
assert 'name = "octo-org"' in (repo_dir / "pyproject.toml").read_text(
237+
encoding="utf-8"
238+
)
239+
assert "12345+octocat@users.noreply.github.com" in (
240+
repo_dir / "pyproject.toml"
241+
).read_text(encoding="utf-8")

0 commit comments

Comments
 (0)