diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..96b2d7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + # The only thing that can be wrong with a template is what it generates, so the + # check is the generated project's own gate: render with the defaults, then run + # exactly what a new library's CI would run. + render: + name: Render the template and run the generated gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + + - name: Render with copier + run: | + uvx --with jinja2-time copier copy --defaults --trust --vcs-ref HEAD \ + --data project_name=demo-lib \ + --data project_slug=demo-lib \ + --data package_name=demo_lib \ + --data project_description="Demo library rendered from the template" \ + --data author_name="Bedrock Python" \ + --data author_email="maintainers@example.com" \ + . "${RUNNER_TEMP}/demo-lib" + + - name: The generated project passes its own gate + working-directory: ${{ runner.temp }}/demo-lib + run: | + uv sync --group dev --all-extras + make check + make test-unit + uv build + + all-checks-passed: + name: All checks passed + if: always() + needs: [render] + runs-on: ubuntu-latest + steps: + - name: Every job above succeeded + run: echo '${{ toJSON(needs) }}' | jq -e 'all(.[]; .result == "success")' diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d97645f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,54 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing standards of acceptable +behavior and will take appropriate and fair corrective action in response to any behavior +deemed inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to +the project maintainer at **shalaevad.alexey@gmail.com**. + +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fce10a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to python-library-template + +This repository is the Copier template every bedrock-python library starts from, so a +change here lands in the next library generated — and, through `copier update`, in the +existing ones. Treat it like library code. + +## What a change looks like + +- Files under `template/` are what gets generated; `.jinja` files are rendered by Copier, + everything else is copied as is. Copier's own variables come from `copier.yml`. +- `scripts/setup_repo.py` configures a freshly created GitHub repository to the org + standard (ruleset on `master`, security settings, merge settings, topics). It is run + once per new library and is also what keeps the existing repositories aligned. +- `NEW_LIBRARY_CHECKLIST.md.jinja` and `.claude/LIBRARY_CREATION.md` are the operator + and agent instructions. Keep them in step with what the template actually does. + +## Checking a change + +Render the template and run the generated project's own gate — that is exactly what CI +does: + +```bash +uvx --with jinja2-time copier copy --defaults --trust --vcs-ref HEAD \ + --data project_name=demo-lib --data project_slug=demo-lib --data package_name=demo_lib \ + --data project_description="Demo" --data author_name="You" --data author_email="you@example.com" \ + . /tmp/demo-lib +cd /tmp/demo-lib && uv sync --group dev --all-extras && make check && make test-unit && uv build +``` + +A change to `setup_repo.py` is checked by running it against a throwaway repository, or +against one of the org's repositories with `--help` first: the script is idempotent, so a +re-run on an already configured repository is a no-op. + +## Commit messages + +[Conventional Commits](https://www.conventionalcommits.org/): `feat:` for something new in +the generated project, `fix:` for a bug in it, `ci:` for workflow and setup-script +changes, `docs:` for the instructions. There is no release; the template is consumed by +git ref. + +## Pull requests + +Branch from `master`, open a PR against it. `master` takes pull requests only and needs +the "All checks passed" status. diff --git a/NEW_LIBRARY_CHECKLIST.md.jinja b/NEW_LIBRARY_CHECKLIST.md.jinja index 210cc43..701ea08 100644 --- a/NEW_LIBRARY_CHECKLIST.md.jinja +++ b/NEW_LIBRARY_CHECKLIST.md.jinja @@ -31,11 +31,15 @@ then run from the template repo: python /path/to/python-library-template/scripts/setup_repo.py {{ github_org }}/{{ project_slug }} ``` -This configures: -- GitHub environments (`pypi`, `github-pages`) -- GitHub Pages (source: GitHub Actions) -- Actions permissions (allow creating PRs for Release Please) -- Branch protection on `master` requiring `All checks passed` +This configures (idempotent, re-run any time): +- GitHub environments (`pypi`, `github-pages`) and Pages (source: GitHub Actions) +- Actions: read-only workflow token by default, Release Please may open PRs +- Merge settings: squash or merge commit, branches deleted on merge, no wiki/projects, + docs site as the homepage, topics from `pyproject` keywords +- Security: secret scanning, push protection, Dependabot alerts + security updates, + private vulnerability reporting +- A `master` ruleset: pull requests only, no force-push or deletion, `All checks passed` + required (left out with a warning if CI has not reported yet — re-run after it has) ## Step 4 — PyPI Trusted Publisher (manual) diff --git a/README.md b/README.md index dad5004..d4d0968 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,16 @@ make check Then: 1. Create a GitHub repo: `gh repo create bedrock-python/my-library --public` -2. **Configure repo** (run once, then delete): +2. **Configure repo** (after the first CI pass, then delete the script): ```bash python scripts/setup_repo.py bedrock-python/my-library git rm scripts/setup_repo.py .claude/LIBRARY_CREATION.md ``` + Idempotent. Sets the org standard: `pypi`/`github-pages` environments and Pages, + read-only workflow tokens, squash/merge-commit only with branches deleted on merge, + secret scanning + push protection + Dependabot security updates + private + vulnerability reporting, topics from `pyproject` keywords, and a `master` ruleset + (pull requests only, no force-push or deletion, "All checks passed" required). 3. **Push** (⚠️ **no** `Co-Authored-By:` in commits!): ```bash git init && git add . && git commit -m "feat: initial release" && git push -u origin master diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ea65384 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Reporting a vulnerability + +**Please do not report security vulnerabilities via public GitHub Issues.** + +Report it privately through GitHub, by +[opening a draft security advisory](https://github.com/bedrock-python/python-library-template/security/advisories/new), +or send an email to **shalaevad.alexey@gmail.com**. Either way, include: + +- Description of the vulnerability +- Steps to reproduce +- Potential impact and affected versions + +We aim to acknowledge reports within **48 hours** and provide a fix within **7 days** +for critical issues. + +Once the fix is released, we will credit you in the release notes unless you prefer +to remain anonymous. diff --git a/scripts/setup_repo.py b/scripts/setup_repo.py index 223f643..e990e69 100644 --- a/scripts/setup_repo.py +++ b/scripts/setup_repo.py @@ -1,91 +1,181 @@ -"""One-time GitHub repo setup script for new libraries. +"""One-time GitHub repository setup for bedrock-python libraries. Idempotent: safe to re-run. Usage: - python scripts/setup_repo.py - python scripts/setup_repo.py bedrock-python/my-new-lib + python scripts/setup_repo.py [--topics a,b,c] -Run AFTER the first CI pass so "All checks passed" exists in GitHub. +Run AFTER the first CI pass: the master ruleset requires the "All checks passed" +status, and a required check that never reports would block every merge. When the +check is not found on the default branch the rule is left out and a warning printed; +re-run once CI has been green, or pass --require-check when CI only runs on pull +requests (the check is then never on the branch itself). + +Needs the gh CLI, authenticated with admin rights on the repository. """ +from __future__ import annotations + +import argparse +import base64 import json +import re import subprocess import sys +import tomllib +RULESET_NAME = "master-rules" +REQUIRED_CHECK = "All checks passed" +MAX_TOPICS = 20 -def gh(*args: str, input: str | None = None, silent: bool = False) -> str: - result = subprocess.run( - ["gh", *args], - input=input, - capture_output=True, - text=True, - ) - if result.returncode != 0 and not silent: - print(f" [FAIL] gh {' '.join(args)}") - print(f" {result.stderr.strip()}") - return result.stdout.strip() +def gh(*args: str, payload: dict | list | None = None, ok: tuple[int, ...] = ()) -> dict | list | str | None: + """Call `gh api`; return parsed JSON. HTTP statuses in `ok` are tolerated (returns None).""" + cmd = ["gh", "api", *args] + if payload is not None: + cmd += ["--input", "-"] + result = subprocess.run(cmd, input=json.dumps(payload) if payload is not None else None, capture_output=True, text=True) + if result.returncode != 0: + status = re.search(r"HTTP (\d{3})", result.stderr) + if status and int(status.group(1)) in ok: + return None + sys.exit(f" [FAIL] gh api {' '.join(args)}\n {result.stderr.strip()}") + out = result.stdout.strip() + if not out: + return None + try: + return json.loads(out) + except json.JSONDecodeError: + return out + + +def step(title: str) -> None: + print(f" -> {title}") -def main() -> None: - if len(sys.argv) != 2: - print("Usage: python scripts/setup_repo.py ") - sys.exit(1) - repo = sys.argv[1] +def topics_from_pyproject(repo: str, default_branch: str) -> list[str]: + data = gh(f"repos/{repo}/contents/pyproject.toml?ref={default_branch}", ok=(404,)) + if not data: + return [] + project = tomllib.loads(base64.b64decode(data["content"]).decode()).get("project", {}) + return list(project.get("keywords", [])) + + +def sanitize_topic(raw: str) -> str | None: + topic = re.sub(r"[^a-z0-9-]+", "-", raw.strip().lower()).strip("-") + return topic[:50] or None + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("repo", help="org/repo") + parser.add_argument("--topics", default="", help="comma-separated topics added on top of pyproject keywords") + parser.add_argument("--require-check", action="store_true", + help=f"require '{REQUIRED_CHECK}' even when it is not found on the default branch (a CI that only runs on pull requests)") + args = parser.parse_args() + repo = args.repo org, name = repo.split("/", 1) - print(f"Setting up {repo}...\n") - - # -- Environments --------------------------------------------------------- - print(" -> Creating environments...") - gh("api", f"repos/{repo}/environments/pypi", "-X", "PUT", silent=True) - gh("api", f"repos/{repo}/environments/github-pages", "-X", "PUT", silent=True) - - # -- GitHub Pages --------------------------------------------------------- - print(" -> Enabling GitHub Pages (GitHub Actions source)...") - gh( - "api", f"repos/{repo}/pages", - "-X", "POST", - "-H", "Accept: application/vnd.github+json", - "-f", "build_type=workflow", - silent=True, - ) - - # -- Actions: allow creating PRs ------------------------------------------- - print(" -> Allowing Actions to create pull requests...") - gh( - "api", f"repos/{repo}/actions/permissions/workflow", - "-X", "PUT", - "-f", "default_workflow_permissions=write", - "-F", "can_approve_pull_request_reviews=true", - ) - - # -- Branch protection ----------------------------------------------------- - print(" -> Setting branch protection on master...") - protection = json.dumps({ - "required_status_checks": { - "strict": True, - "checks": [{"context": "All checks passed"}], - }, - "enforce_admins": False, - "required_pull_request_reviews": None, - "restrictions": None, - "allow_force_pushes": False, - "allow_deletions": False, - }) - gh( - "api", f"repos/{repo}/branches/master/protection", - "-X", "PUT", - "-H", "Accept: application/vnd.github+json", - "--input", "-", - input=protection, - ) - - print("\n[OK] Done!\n") + meta = gh(f"repos/{repo}") + default_branch = meta["default_branch"] + print(f"Setting up {repo} (default branch: {default_branch})\n") + + def has_workflow(name: str) -> bool: + return gh(f"repos/{repo}/contents/.github/workflows/{name}?ref={default_branch}", ok=(404,)) is not None + + # Environments and Pages follow the workflows that use them, so a repository + # without a publish or docs workflow (the template itself) gets neither. + pages = None + if has_workflow("publish.yml"): + step("Environment: pypi (Trusted Publishing)") + gh(f"repos/{repo}/environments/pypi", "-X", "PUT") + if has_workflow("docs.yml"): + step("Environment: github-pages; Pages built by Actions") + gh(f"repos/{repo}/environments/github-pages", "-X", "PUT") + pages = gh(f"repos/{repo}/pages", ok=(404,)) + if pages is None: + gh(f"repos/{repo}/pages", "-X", "POST", payload={"build_type": "workflow"}, ok=(409,)) + pages = gh(f"repos/{repo}/pages", ok=(404,)) + + step("Actions: workflows get a read-only token unless they ask for more; Release Please may open PRs") + gh(f"repos/{repo}/actions/permissions/workflow", "-X", "PUT", + payload={"default_workflow_permissions": "read", "can_approve_pull_request_reviews": True}) + + step("Repository: squash or merge commits only, branches deleted on merge, no wiki/projects, docs as homepage") + settings: dict = { + "allow_squash_merge": True, + "allow_merge_commit": True, + "allow_rebase_merge": False, + "delete_branch_on_merge": True, + "has_wiki": False, + "has_projects": False, + } + if pages and not meta.get("homepage"): + settings["homepage"] = f"https://{org}.github.io/{name}/" + gh(f"repos/{repo}", "-X", "PATCH", payload=settings) + + step("Security: secret scanning + push protection, Dependabot alerts + security updates, private reporting") + gh(f"repos/{repo}", "-X", "PATCH", payload={"security_and_analysis": { + "secret_scanning": {"status": "enabled"}, + "secret_scanning_push_protection": {"status": "enabled"}, + }}) + gh(f"repos/{repo}/vulnerability-alerts", "-X", "PUT") + gh(f"repos/{repo}/automated-security-fixes", "-X", "PUT") + gh(f"repos/{repo}/private-vulnerability-reporting", "-X", "PUT") + + step("Topics: pyproject keywords + python (existing topics kept)") + wanted = ["python", *topics_from_pyproject(repo, default_branch), *args.topics.split(",")] + topics: list[str] = list(meta.get("topics") or []) + for raw in wanted: + topic = sanitize_topic(raw) + if topic and topic not in topics: + topics.append(topic) + gh(f"repos/{repo}/topics", "-X", "PUT", payload={"names": topics[:MAX_TOPICS]}) + + step(f"Ruleset '{RULESET_NAME}' on {default_branch}: no deletion, no force-push, pull requests only") + check_runs = gh(f"repos/{repo}/commits/{default_branch}/check-runs", ok=(404, 422)) or {} + has_check = args.require_check or any(run["name"] == REQUIRED_CHECK for run in check_runs.get("check_runs", [])) + rules: list[dict] = [ + {"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, + "allowed_merge_methods": ["merge", "squash"], + }}, + ] + if has_check: + rules.append({"type": "required_status_checks", "parameters": { + "strict_required_status_checks_policy": False, + "do_not_enforce_on_create": False, + "required_status_checks": [{"context": REQUIRED_CHECK}], + }}) + else: + print(f" [WARN] no '{REQUIRED_CHECK}' check on {default_branch} yet — required-check rule left out; re-run after CI is green") + ruleset = { + "name": RULESET_NAME, + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": rules, + } + existing = next((r for r in gh(f"repos/{repo}/rulesets") or [] if r["name"] == RULESET_NAME), None) + if existing: + gh(f"repos/{repo}/rulesets/{existing['id']}", "-X", "PUT", payload=ruleset) + else: + gh(f"repos/{repo}/rulesets", "-X", "POST", payload=ruleset) + + step("Classic branch protection: removed (the ruleset replaces it)") + if gh(f"repos/{repo}/branches/{default_branch}/protection", ok=(404,)) is not None: + gh(f"repos/{repo}/branches/{default_branch}/protection", "-X", "DELETE") + + print("\n[OK] Done.\n") print("Remaining manual steps:") print(" 1. PyPI Trusted Publisher -> https://pypi.org/manage/account/publishing/") - print(f" project: {name} | org: {org} | repo: {name} | workflow: publish.yml | env: pypi") - print(f" 2. CODECOV_TOKEN -> https://app.codecov.io/gh/{repo}") - print(" GitHub repo -> Settings -> Secrets -> Actions -> CODECOV_TOKEN") + print(f" project: {name} | owner: {org} | repository: {name} | workflow: publish.yml | environment: pypi") + print(f" 2. CODECOV_TOKEN -> https://app.codecov.io/gh/{repo} -> repo Settings -> Secrets -> Actions") if __name__ == "__main__": diff --git a/template/SECURITY.md.jinja b/template/SECURITY.md.jinja index 7931cab..18821bd 100644 --- a/template/SECURITY.md.jinja +++ b/template/SECURITY.md.jinja @@ -4,16 +4,23 @@ | Version | Supported | |---------|-----------| -| 0.1.x | ✅ | +| the latest release | ✅ fixes ship as the next patch or minor | +| older | ❌ upgrade to the latest release first | ## Reporting a vulnerability **Please do not report security vulnerabilities via public GitHub Issues.** -Send a report to **{{ author_email }}** with: +Report it privately through GitHub, by +[opening a draft security advisory](https://github.com/{{ github_org }}/{{ project_slug }}/security/advisories/new), +or send an email to **{{ author_email }}**. Either way, include: - Description of the vulnerability - Steps to reproduce - Potential impact and affected versions -We aim to acknowledge reports within **48 hours** and provide a fix within **7 days** for critical issues. +We aim to acknowledge reports within **48 hours** and provide a fix within **7 days** +for critical issues. + +Once the fix is released, we will credit you in the release notes unless you prefer +to remain anonymous.