diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b70be96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install -e . + python -m pip install -r requirements.txt + - name: Test + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..136bd44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +.dist/ +build/ +dist/ +.venv/ +venv/ +.env/ +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.mypy_cache/ +.ruff_cache/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 73c3ede..c268b38 100644 --- a/README.md +++ b/README.md @@ -1 +1,111 @@ -# platform-bootstrap \ No newline at end of file +# platform-bootstrap + +A privacy-safe, generic bootstrapper for GitHub repositories. + +This project demonstrates a common platform engineering pattern: +automating baseline standards (ownership, CI, contribution guidance, +and branch protection) so teams can move quickly without sacrificing +guardrails. + +It is intentionally designed to run safely in dry-run mode and does not +perform destructive writes unless explicitly configured. + +--- + +## Why this exists + +Platform teams frequently standardize: + +- Repository hygiene +- CODEOWNERS enforcement +- CI defaults +- Security documentation +- Branch protection rules + +Rather than relying on manual setup or tribal knowledge, this tool encodes +those practices into repeatable automation. + +The goal is consistency, not rigidity. + +--- + +## Features + +- Repository bootstrap CLI +- Dry-run mode for safe previews +- GitHub API client abstraction +- Baseline file scaffolding: + - `CODEOWNERS` + - `SECURITY.md` + - `CONTRIBUTING.md` + - CI workflow template +- Branch protection configuration (stubbed for v1) + +--- + +## Install + +```bash +python -m pip install -e . +``` + +--- + +## Usage + +```bash +platform-bootstrap bootstrap \ + --owner my-org \ + --repo my-repo \ + --default-branch main \ + --codeowners "@my-org/platform" \ + --dry-run +``` + +--- + +## Behavior + +The bootstrapper will: + +- Ensure the repository exists +- Upsert baseline files +- Add a minimal CI workflow +- Configure branch protection + +In dry-run mode, it prints intended actions without network writes. + +Non-dry-run mode retains GitHub API method signatures and provides +clear TODO markers for full implementation. + +--- + +## Architecture + +``` +CLI → Bootstrap Service → GitHubClient → GitHub API +``` + +- CLI handles argument parsing +- Bootstrap service defines platform policy +- GitHubClient abstracts API interaction +- GitHub API executes repository configuration + +--- + +## Design Notes + +- Uses a `src/` layout for clean packaging. +- Separates CLI, bootstrap logic, and GitHub API client. +- Designed to evolve into a reusable internal SDK or service. +- Emphasizes safety-first automation via dry-run support. + +--- + +## Roadmap + +- Full GitHub file upsert implementation +- Org-level policy packs +- Template repository support +- Optional FastAPI wrapper for service deployment +- Policy-as-code integration patterns diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dfbea78 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "platform-bootstrap" +version = "0.1.0" +description = "A privacy-safe bootstrapper for GitHub repositories." +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "Platform Engineering", email = "platform@example.com"} +] +dependencies = [ + "requests>=2.31" +] + +[project.scripts] +platform-bootstrap = "platform_bootstrap.cli:main" + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-q" +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b197d32 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pytest>=7.0 diff --git a/src/platform_bootstrap/__init__.py b/src/platform_bootstrap/__init__.py new file mode 100644 index 0000000..7f93114 --- /dev/null +++ b/src/platform_bootstrap/__init__.py @@ -0,0 +1,4 @@ +"""Platform bootstrap package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/src/platform_bootstrap/bootstrap.py b/src/platform_bootstrap/bootstrap.py new file mode 100644 index 0000000..a489081 --- /dev/null +++ b/src/platform_bootstrap/bootstrap.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from platform_bootstrap.github_client import GitHubClient + + +@dataclass +class BootstrapOptions: + owner: str + repo: str + default_branch: str + codeowners: str + dry_run: bool = False + + +def _codeowners_content(codeowners: str) -> str: + return f"* {codeowners}\n" + + +def _security_md() -> str: + return ( + "# Security Policy\n\n" + "If you discover a security issue, please report it privately.\n" + "Do not open public issues with sensitive details.\n" + ) + + +def _contributing_md() -> str: + return ( + "# Contributing\n\n" + "Thanks for your interest in improving this project.\n" + "Please open an issue to discuss major changes before submitting a PR.\n" + ) + + +def _ci_workflow() -> str: + return ( + "name: CI\n\n" + "on:\n" + " push:\n" + " branches: [\"main\"]\n" + " pull_request:\n\n" + "jobs:\n" + " test:\n" + " runs-on: ubuntu-latest\n" + " strategy:\n" + " matrix:\n" + " python-version: [\"3.10\", \"3.11\", \"3.12\"]\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + " - uses: actions/setup-python@v5\n" + " with:\n" + " python-version: ${{ matrix.python-version }}\n" + " - name: Install\n" + " run: python -m pip install -e .\n" + " - name: Test\n" + " run: pytest\n" + ) + + +def bootstrap_repo( + *, + owner: str, + repo: str, + default_branch: str, + codeowners: str, + dry_run: bool = False, +) -> None: + options = BootstrapOptions( + owner=owner, + repo=repo, + default_branch=default_branch, + codeowners=codeowners, + dry_run=dry_run, + ) + + client = GitHubClient.from_env(dry_run=options.dry_run) + + if options.dry_run: + print("[dry-run] starting bootstrap") + + exists = client.ensure_repo(options.owner, options.repo) + if not exists and not options.dry_run: + raise NotImplementedError( + "TODO: Implement repository creation for non-existent repos." + ) + + client.upsert_file( + owner=options.owner, + repo=options.repo, + path="CODEOWNERS", + content=_codeowners_content(options.codeowners), + message="chore: add CODEOWNERS", + branch=options.default_branch, + ) + client.upsert_file( + owner=options.owner, + repo=options.repo, + path="SECURITY.md", + content=_security_md(), + message="chore: add SECURITY policy", + branch=options.default_branch, + ) + client.upsert_file( + owner=options.owner, + repo=options.repo, + path="CONTRIBUTING.md", + content=_contributing_md(), + message="chore: add CONTRIBUTING guide", + branch=options.default_branch, + ) + client.upsert_file( + owner=options.owner, + repo=options.repo, + path=".github/workflows/ci.yml", + content=_ci_workflow(), + message="chore: add CI workflow", + branch=options.default_branch, + ) + + client.set_branch_protection( + owner=options.owner, + repo=options.repo, + branch=options.default_branch, + ) + + if options.dry_run: + print("[dry-run] bootstrap complete") diff --git a/src/platform_bootstrap/cli.py b/src/platform_bootstrap/cli.py new file mode 100644 index 0000000..38c6e96 --- /dev/null +++ b/src/platform_bootstrap/cli.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import argparse +import sys + +from platform_bootstrap.bootstrap import bootstrap_repo + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="platform-bootstrap", + description="Bootstrap a GitHub repository with baseline files and policies.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + bootstrap_parser = subparsers.add_parser( + "bootstrap", help="Apply baseline bootstrap to a repository" + ) + bootstrap_parser.add_argument("--owner", required=True) + bootstrap_parser.add_argument("--repo", required=True) + bootstrap_parser.add_argument("--default-branch", default="main") + bootstrap_parser.add_argument("--codeowners", required=True) + bootstrap_parser.add_argument("--dry-run", action="store_true") + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "bootstrap": + bootstrap_repo( + owner=args.owner, + repo=args.repo, + default_branch=args.default_branch, + codeowners=args.codeowners, + dry_run=args.dry_run, + ) + return 0 + + parser.print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/platform_bootstrap/github_client.py b/src/platform_bootstrap/github_client.py new file mode 100644 index 0000000..7c45f52 --- /dev/null +++ b/src/platform_bootstrap/github_client.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +from typing import Optional + +import requests + + +@dataclass +class GitHubClient: + token: Optional[str] + dry_run: bool = False + base_url: str = "https://api.github.com" + + @classmethod + def from_env(cls, *, dry_run: bool = False) -> "GitHubClient": + token = os.getenv("GITHUB_TOKEN") + if not token and not dry_run: + raise ValueError("GITHUB_TOKEN is required for non-dry-run operations.") + if not token and dry_run: + print("[dry-run] GITHUB_TOKEN not set; using unauthenticated mode.") + return cls(token=token, dry_run=dry_run) + + def _headers(self) -> dict: + headers = {"Accept": "application/vnd.github+json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + return headers + + def ensure_repo(self, owner: str, repo: str) -> bool: + if self.dry_run: + print(f"[dry-run] ensure repo exists: {owner}/{repo}") + return True + url = f"{self.base_url}/repos/{owner}/{repo}" + response = requests.get(url, headers=self._headers(), timeout=10) + if response.status_code == 200: + return True + if response.status_code == 404: + return False + raise RuntimeError( + f"GitHub API error ({response.status_code}): {response.text}" + ) + + def upsert_file( + self, + *, + owner: str, + repo: str, + path: str, + content: str, + message: str, + branch: str, + ) -> None: + if self.dry_run: + print( + "[dry-run] upsert file: " + f"{owner}/{repo}:{path} (branch={branch})" + ) + return + raise NotImplementedError( + "TODO: Implement GitHub contents API call for upserting files." + ) + + def set_branch_protection(self, *, owner: str, repo: str, branch: str) -> None: + if self.dry_run: + print( + "[dry-run] set branch protection: " + f"{owner}/{repo}:{branch}" + ) + return + raise NotImplementedError( + "TODO: Implement branch protection API call." + ) diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..5c67d9a --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,11 @@ +from platform_bootstrap.bootstrap import bootstrap_repo + + +def test_bootstrap_dry_run_smoke() -> None: + bootstrap_repo( + owner="octo-org", + repo="demo", + default_branch="main", + codeowners="@octo-org/platform", + dry_run=True, + )