From cfd8f355bca3518d1e30f11f839692d960cc6921 Mon Sep 17 00:00:00 2001 From: bluesentinelsec Date: Mon, 27 Jul 2026 12:28:35 -0400 Subject: [PATCH] Add lint, package build CI, and release-triggered PyPI publish workflow Single-source version in _version.py. CI runs ruff, mypy, pytest, and twine-checked sdist/wheel builds. Publish workflow wires GitHub Releases to PyPI Trusted Publishing (OIDC) and TestPyPI via dispatch, without publishing until a release is intentionally created. --- .github/workflows/ci.yml | 53 +++++++++ .github/workflows/publish.yml | 147 +++++++++++++++++++++++++ CHANGELOG.md | 19 +++- README.md | 37 +++++++ pyproject.toml | 48 +++++++- src/cppboot/__init__.py | 3 +- src/cppboot/_version.py | 5 + src/cppboot/cli.py | 20 +--- src/cppboot/generate/build_wrappers.py | 4 +- src/cppboot/generate/docs.py | 2 +- src/cppboot/generate/project.py | 5 +- src/cppboot/generate/sources.py | 10 +- src/cppboot/licenses.py | 7 +- tests/integration/test_generate.py | 2 +- tests/unit/test_cli.py | 4 +- tests/unit/test_version.py | 34 ++++++ 16 files changed, 352 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 src/cppboot/_version.py create mode 100644 tests/unit/test_version.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6766bd..5b5e327 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,59 @@ concurrency: cancel-in-progress: true jobs: + lint: + name: lint (ruff + mypy) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package (dev extras) + run: python -m pip install -e ".[dev]" + + - name: Ruff check + run: python -m ruff check src tests + + - name: Ruff format (check only) + run: python -m ruff format --check src tests + + - name: Mypy + run: python -m mypy + + build: + name: build sdist/wheel + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tooling + run: python -m pip install -e ".[dev]" + + - name: Build distributions + run: python -m build + + - name: Twine check + run: python -m twine check dist/* + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + retention-days: 7 + test: name: pytest (py${{ matrix.python-version }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..149083d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,147 @@ +# Publish cppboot to PyPI when a GitHub Release is published. +# +# Idiomatic flow: +# 1. Bump src/cppboot/_version.py (and CHANGELOG) +# 2. Merge to main +# 3. Create a GitHub Release with tag vX.Y.Z (must match package version) +# 4. This workflow builds sdist/wheel and uploads via Trusted Publishing (OIDC) +# +# Prerequisites (one-time, before first real publish — not done by this PR): +# - PyPI project "cppboot" with Trusted Publisher: +# Owner: bluesentinelsec, Repository: cppboot, +# Workflow: publish.yml, Environment: pypi +# - Optional TestPyPI trusted publisher with Environment: testpypi +# - GitHub Environments "pypi" / "testpypi" (optional protection rules) +# +# This workflow does not run until a Release is published (or manual dispatch). +# Do not publish until you are intentionally ready for PyPI. +name: Publish + +on: + release: + types: [published] + workflow_dispatch: + inputs: + target: + description: "Publish target (use testpypi until ready for real PyPI)" + type: choice + options: + - testpypi + - pypi + default: testpypi + +permissions: + contents: read + +concurrency: + group: publish-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + outputs: + package_version: ${{ steps.meta.outputs.package_version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tooling + run: python -m pip install -e ".[dev]" + + - name: Read package version + id: meta + run: | + set -euo pipefail + version="$(python -c 'from cppboot._version import __version__; print(__version__)')" + echo "package_version=${version}" >> "${GITHUB_OUTPUT}" + echo "Package version: ${version}" + + - name: Verify release tag matches package version + if: github.event_name == 'release' + env: + TAG_NAME: ${{ github.event.release.tag_name }} + PACKAGE_VERSION: ${{ steps.meta.outputs.package_version }} + run: | + set -euo pipefail + tag="${TAG_NAME#v}" + tag="${tag#V}" + if [ "${tag}" != "${PACKAGE_VERSION}" ]; then + echo "Release tag '${TAG_NAME}' (normalized '${tag}') does not match" + echo "package version '${PACKAGE_VERSION}' from src/cppboot/_version.py" + echo "Bump _version.py and CHANGELOG before creating the GitHub Release." + exit 1 + fi + echo "Tag and package version match: ${PACKAGE_VERSION}" + + - name: Build sdist and wheel + run: python -m build + + - name: Twine check + run: python -m twine check dist/* + + - name: List artifacts + run: ls -la dist/ + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + retention-days: 14 + + publish-pypi: + name: Publish to PyPI + needs: build + if: > + github.event_name == 'release' + || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/cppboot + permissions: + id-token: write # required for Trusted Publishing (OIDC) + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + print-hash: true + + publish-testpypi: + name: Publish to TestPyPI + needs: build + if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/cppboot + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + repository-url: https://test.pypi.org/legacy/ + print-hash: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cee26c..289e27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,20 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- Split the monolithic generator into a `cppboot.generate` package (templates, - tooling, orchestration) while keeping `cppboot.generator` as a stable facade. -- Added package `LICENSE`, `CHANGELOG.md`, and `py.typed` for a PyPI-ready layout. - ### Added +- **Publish** workflow: GitHub Release (`vX.Y.Z`) builds sdist/wheel and uploads + to PyPI via Trusted Publishing (OIDC). Manual `workflow_dispatch` can target + TestPyPI. Release tag must match `src/cppboot/_version.py`. +- Ruff + mypy in CI; distribution build + `twine check` job. +- Dev extras: `ruff`, `mypy`, `build`, `twine`. - Opt-out flags for default-on behavior: `--no-git`, `--no-fmt`, `--no-community-docs` (alongside existing `--no-vim`, `--no-ctags`, `--no-vscode`, `--no-github-actions`, `--no-codespaces`). - Offline license mode for deterministic tests (`ProjectOptions.offline_license`). - pytest unit and integration suite with multi-Python CI. +### Changed + +- Package version single-sourced from `src/cppboot/_version.py` (setuptools + dynamic version). +- Split the monolithic generator into a `cppboot.generate` package (templates, + tooling, orchestration) while keeping `cppboot.generator` as a stable facade. +- Added package `LICENSE`, `CHANGELOG.md`, and `py.typed` for a PyPI-ready layout. + ## [0.1.0] - unreleased on PyPI Initial public development line: CMake C++20 scaffolds, VERSION single source, diff --git a/README.md b/README.md index be97b0f..e37186b 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ Developers can start work with a clean `git status` and predictable formatting. ```bash python3 -m pip install -e ".[dev]" python3 -m pytest -q +python3 -m ruff check src tests +python3 -m ruff format --check src tests +python3 -m mypy +python3 -m build && python3 -m twine check dist/* ``` Tests cover CLI parsing, name/license logic, and filesystem generation with @@ -92,6 +96,39 @@ Optional full C++ smoke (local only, not required for CI): bash scripts/smoke.sh ``` +### Releasing to PyPI (when ready) + +Package version lives in **`src/cppboot/_version.py`** (single source; also used by +setuptools dynamic version). + +1. Bump `__version__` in `_version.py` and update `CHANGELOG.md` +2. Merge to `main` (CI must be green) +3. Create a **GitHub Release** with tag **`vX.Y.Z`** matching that version + (GitHub UI: Releases → Draft a new release, or `gh release create vX.Y.Z`) +4. Workflow **Publish** (`.github/workflows/publish.yml`) runs on + `release: published`: + - Verifies tag `vX.Y.Z` == package version + - Builds sdist + wheel + - Uploads to **PyPI** via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) + (OIDC; no long-lived API token in the repo) + +Manual dry-run target (does **not** publish to real PyPI unless you choose it): + +```bash +gh workflow run publish.yml -f target=testpypi +``` + +**One-time setup before the first real publish** (not automated here): + +1. Create the empty project on [PyPI](https://pypi.org/) (or claim `cppboot` on first upload) +2. Add a **Trusted Publisher**: owner `bluesentinelsec`, repo `cppboot`, + workflow `publish.yml`, environment `pypi` +3. Optionally configure TestPyPI the same way with environment `testpypi` +4. Create GitHub Environments `pypi` / `testpypi` (optional protection rules) + +Until that setup is done, the publish job will fail at the upload step — which is +expected and safe while developing. + ### Package layout ```text diff --git a/pyproject.toml b/pyproject.toml index 4dcd41a..ecb646b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cppboot" -version = "0.1.0" +dynamic = ["version"] description = "Bootstrap a professional-grade C++ project environment" readme = "README.md" requires-python = ">=3.10" @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Build Tools", "Topic :: Software Development :: Code Generators", + "Typing :: Typed", ] dependencies = [] @@ -31,6 +32,10 @@ dependencies = [] dev = [ "pytest>=7.0", "pytest-cov>=4.0", + "ruff>=0.9.0", + "mypy>=1.8.0", + "build>=1.2.0", + "twine>=5.0.0", ] [project.scripts] @@ -40,6 +45,10 @@ cppboot = "cppboot.cli:main" Homepage = "https://github.com/bluesentinelsec/cppboot" Repository = "https://github.com/bluesentinelsec/cppboot" Issues = "https://github.com/bluesentinelsec/cppboot/issues" +Changelog = "https://github.com/bluesentinelsec/cppboot/blob/main/CHANGELOG.md" + +[tool.setuptools.dynamic] +version = { attr = "cppboot._version.__version__" } [tool.setuptools.packages.find] where = ["src"] @@ -60,3 +69,40 @@ markers = [ [tool.coverage.run] source = ["cppboot"] branch = true + +[tool.ruff] +target-version = "py310" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade + "SIM", # simplify +] +ignore = [ + "E501", # line length handled by formatter preference; templates are long +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B011"] +# Embedded Makefile/batch/CMake templates intentionally mix tabs and spaces. +"src/cppboot/generate/**" = ["E101"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.mypy] +python_version = "3.10" +mypy_path = "src" +packages = ["cppboot"] +strict = true +warn_unused_configs = true +show_error_codes = true +pretty = true diff --git a/src/cppboot/__init__.py b/src/cppboot/__init__.py index d51ba50..35e5c47 100644 --- a/src/cppboot/__init__.py +++ b/src/cppboot/__init__.py @@ -2,10 +2,9 @@ from __future__ import annotations +from cppboot._version import __version__ from cppboot.generator import GenerateResult, ProjectOptions, generate_project -__version__ = "0.1.0" - __all__ = [ "GenerateResult", "ProjectOptions", diff --git a/src/cppboot/_version.py b/src/cppboot/_version.py new file mode 100644 index 0000000..88f5e4e --- /dev/null +++ b/src/cppboot/_version.py @@ -0,0 +1,5 @@ +"""Single source of truth for the installed package version.""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/src/cppboot/cli.py b/src/cppboot/cli.py index 3c29042..1bc91ec 100644 --- a/src/cppboot/cli.py +++ b/src/cppboot/cli.py @@ -7,7 +7,7 @@ import sys from pathlib import Path -from cppboot import __version__ +from cppboot._version import __version__ from cppboot.generator import ProjectOptions, generate_project from cppboot.licenses import DEFAULT_LICENSE, LICENSE_CHOICES @@ -70,18 +70,14 @@ def build_parser() -> argparse.ArgumentParser: "--vscode", action=argparse.BooleanOptionalAction, default=True, - help=( - "Write VS Code config + CMakePresets (default: on). " - "Use --no-vscode to skip." - ), + help=("Write VS Code config + CMakePresets (default: on). Use --no-vscode to skip."), ) parser.add_argument( "--github-actions", action=argparse.BooleanOptionalAction, default=True, help=( - "Write cross-platform GitHub Actions CI (default: on). " - "Use --no-github-actions to skip." + "Write cross-platform GitHub Actions CI (default: on). Use --no-github-actions to skip." ), ) parser.add_argument( @@ -106,19 +102,13 @@ def build_parser() -> argparse.ArgumentParser: "--git", action=argparse.BooleanOptionalAction, default=True, - help=( - "Run git init and create an initial commit (default: on). " - "Use --no-git to skip." - ), + help=("Run git init and create an initial commit (default: on). Use --no-git to skip."), ) parser.add_argument( "--fmt", action=argparse.BooleanOptionalAction, default=True, - help=( - "Run make fmt after scaffolding (default: on). " - "Use --no-fmt to skip." - ), + help=("Run make fmt after scaffolding (default: on). Use --no-fmt to skip."), ) parser.add_argument( "--community-docs", diff --git a/src/cppboot/generate/build_wrappers.py b/src/cppboot/generate/build_wrappers.py index f3cf97b..02557af 100644 --- a/src/cppboot/generate/build_wrappers.py +++ b/src/cppboot/generate/build_wrappers.py @@ -36,9 +36,7 @@ def _makefile(ctx: _Context) -> str: """ if ctx.with_ctags: phony_extra = " tags" - help_tags = ( - '\t@echo " make tags - regenerate ctags index (Universal Ctags)"\n' - ) + help_tags = '\t@echo " make tags - regenerate ctags index (Universal Ctags)"\n' tags_target = """ tags: @command -v ctags >/dev/null 2>&1 || { echo "ctags not found (install universal-ctags)"; exit 1; } diff --git a/src/cppboot/generate/docs.py b/src/cppboot/generate/docs.py index 63708d0..f52d425 100644 --- a/src/cppboot/generate/docs.py +++ b/src/cppboot/generate/docs.py @@ -273,7 +273,7 @@ def _readme(ctx: _Context) -> str: | `make doc` | `build.bat doc` | Doxygen HTML | | `make clean` | `build.bat clean` | Remove build trees | -Debug tree: `build/debug` +Debug tree: `build/debug` Release tree: `build/release` The Debug configure step links/copies `compile_commands.json` at the repo root for LSP. diff --git a/src/cppboot/generate/project.py b/src/cppboot/generate/project.py index 664f420..51d2aba 100644 --- a/src/cppboot/generate/project.py +++ b/src/cppboot/generate/project.py @@ -189,9 +189,7 @@ def write(relpath: str, content: str) -> None: write(".devcontainer/setup.sh", _devcontainer_setup_sh(ctx)) setup_sh = project_dir / ".devcontainer" / "setup.sh" setup_sh.chmod(setup_sh.stat().st_mode | 0o111) - logger.info( - "wrote GitHub Codespaces / Dev Container config under .devcontainer/" - ) + logger.info("wrote GitHub Codespaces / Dev Container config under .devcontainer/") if ctx.with_github_actions: write(".github/workflows/ci.yml", _github_actions_workflow(ctx)) @@ -233,4 +231,3 @@ def write(relpath: str, content: str) -> None: license_source=license_result.source, formatted=formatted, ) - diff --git a/src/cppboot/generate/sources.py b/src/cppboot/generate/sources.py index e6eff00..60e5fd1 100644 --- a/src/cppboot/generate/sources.py +++ b/src/cppboot/generate/sources.py @@ -143,10 +143,7 @@ def _version_test(ctx: _Context) -> str: if ctx.with_modules: includes = f"import {ctx.namespace}.version;\n\n#include " else: - includes = ( - f'#include "{ctx.namespace}/version.hpp"\n\n' - "#include " - ) + includes = f'#include "{ctx.namespace}/version.hpp"\n\n#include ' return f"""\ /** * @file version_test.cpp @@ -192,10 +189,7 @@ def _version_bench(ctx: _Context) -> str: if ctx.with_modules: includes = f"import {ctx.namespace}.version;\n\n#include " else: - includes = ( - f'#include "{ctx.namespace}/version.hpp"\n\n' - "#include " - ) + includes = f'#include "{ctx.namespace}/version.hpp"\n\n#include ' return f"""\ /** * @file version_bench.cpp diff --git a/src/cppboot/licenses.py b/src/cppboot/licenses.py index 7503ad3..58e69a6 100644 --- a/src/cppboot/licenses.py +++ b/src/cppboot/licenses.py @@ -26,9 +26,7 @@ DEFAULT_LICENSE = "apache-2.0" # Authoritative text sources (SPDX license-list-data). -_SPDX_BASE = ( - "https://raw.githubusercontent.com/spdx/license-list-data/main/text" -) +_SPDX_BASE = "https://raw.githubusercontent.com/spdx/license-list-data/main/text" _LICENSE_URLS: dict[str, str] = { "apache-2.0": f"{_SPDX_BASE}/Apache-2.0.txt", @@ -185,7 +183,8 @@ def _download_text(url: str, timeout: float) -> str: try: context = ssl.create_default_context() with urllib.request.urlopen(request, timeout=timeout, context=context) as response: - return response.read().decode("utf-8") + raw = response.read() + return raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw) except (urllib.error.URLError, TimeoutError, OSError) as first_error: logger.debug("urllib download failed for %s: %s", url, first_error) diff --git a/tests/integration/test_generate.py b/tests/integration/test_generate.py index fadec06..a371021 100644 --- a/tests/integration/test_generate.py +++ b/tests/integration/test_generate.py @@ -6,7 +6,7 @@ import pytest -from cppboot.generator import ProjectOptions, generate_project +from cppboot.generator import generate_project from tests.conftest import minimal_options diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index b5116fe..07b61e7 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -53,9 +53,7 @@ def test_parser_opt_outs() -> None: def test_parser_opt_in_features() -> None: - args = build_parser().parse_args( - ["-n", "demo", "--with-modules", "--shared", "--github"] - ) + args = build_parser().parse_args(["-n", "demo", "--with-modules", "--shared", "--github"]) assert args.with_modules is True assert args.shared is True assert args.github is True diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 0000000..741d0b3 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,34 @@ +"""Version single-source checks for packaging and releases.""" + +from __future__ import annotations + +import re +from importlib import metadata +from pathlib import Path + +import cppboot +from cppboot._version import __version__ as version_module_version + + +def test_public_version_matches_module() -> None: + assert cppboot.__version__ == version_module_version + + +def test_version_is_pep440_ish() -> None: + assert re.fullmatch(r"\d+\.\d+\.\d+([.-].+)?", version_module_version) + + +def test_installed_metadata_matches_when_available() -> None: + """When installed editable/normal, distribution metadata should match.""" + try: + dist_version = metadata.version("cppboot") + except metadata.PackageNotFoundError: + return + assert dist_version == version_module_version + + +def test_version_file_is_single_assignment() -> None: + root = Path(__file__).resolve().parents[2] + text = (root / "src" / "cppboot" / "_version.py").read_text(encoding="utf-8") + matches = re.findall(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.M) + assert matches == [version_module_version]