diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3878235 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Release + +on: + push: + tags: + - "v[0-9]*" + +permissions: + contents: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Build and publish + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: pypi + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install uv and Python + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.32" + python-version: "3.11" + enable-cache: true + + - name: Install locked dependencies + run: uv sync --frozen + + - name: Check formatting + run: uv run ruff format --check . + + - name: Run linting + run: uv run ruff check . + + - name: Run type checking + run: uv run mypy + + - name: Run tests + run: uv run pytest + + - name: Verify release tag + shell: bash + run: | + set -euo pipefail + + package_version="$(uv version --short)" + expected_tag="v${package_version}" + + if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then + echo "Tag ${GITHUB_REF_NAME} does not match ${expected_tag}." >&2 + exit 1 + fi + + if ! grep -Fq "## [${package_version}]" CHANGELOG.md; then + echo "CHANGELOG.md does not document ${package_version}." >&2 + exit 1 + fi + + - name: Build distributions + run: | + rm -rf dist + uv build --no-sources + + - name: Verify distribution contents + run: uv run python scripts/check_distribution.py + + - name: Smoke-test wheel + shell: bash + run: | + set -euo pipefail + + uv venv --python 3.11 .release-venv + + uv pip install --python .release-venv/bin/python dist/*.whl + + .release-venv/bin/runbookproof --version + + .release-venv/bin/runbookproof rules --format json > /tmp/runbookproof-rules.json + + python -m json.tool /tmp/runbookproof-rules.json > /dev/null + + .release-venv/bin/runbookproof scan README.md + + - name: Publish distributions to PyPI + run: uv publish + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" dist/* --verify-tag --generate-notes --title "RunbookProof ${GITHUB_REF_NAME}" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0fca2e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to RunbookProof are documented in this file. + +The format follows Keep a Changelog, and the project uses Semantic Versioning. + +## [Unreleased] + +## [0.1.0] - 2026-08-04 + +### Added + +- Static analysis for commands embedded in Markdown documents +- Recursive directory scanning for Markdown files +- Command extraction from fenced code blocks +- Text, JSON, and SARIF 2.1.0 reports +- Stable finding fingerprints and source locations +- Exit codes suitable for CI and automation +- AWS CLI verification pack +- Azure CLI verification pack +- Bash verification pack +- Docker verification pack +- Git verification pack +- Kubernetes verification pack +- Node package-manager verification pack +- Python package-manager verification pack +- Terraform verification pack +- Universal verification pack +- Rule-level ignore controls +- `.runbookproof.toml` configuration support +- Built-in rule catalogue command +- Generated rule reference documentation +- GitHub Code Scanning integration +- Multi-version CI for Python 3.11 through Python 3.14 +- Strict Ruff, MyPy, and test-coverage validation + +[Unreleased]: https://github.com/antonisloukis/runbookproof/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/antonisloukis/runbookproof/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 80377d5..e9195cc 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,14 @@ uv run pytest git diff --check ``` +## Release process + +Release preparation and publishing instructions are documented in +[`docs/releasing.md`](docs/releasing.md). + +Release history is maintained in +[`CHANGELOG.md`](CHANGELOG.md). + ## Roadmap - Finding suppression with documented justifications diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..a0a5ae3 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,94 @@ +# Releasing RunbookProof + +RunbookProof releases are built, verified, published to PyPI, and attached to a +GitHub release by `.github/workflows/release.yml`. + +## One-time PyPI setup + +Before publishing the first version: + +1. Create or sign in to the PyPI account that will own `runbookproof`. +2. Configure a PyPI Trusted Publisher with: + - Owner: `antonisloukis` + - Repository: `runbookproof` + - Workflow: `release.yml` + - Environment: `pypi` +3. In the GitHub repository, create an environment named `pypi`. +4. Add environment protection rules before publishing production releases. + +No PyPI API token is stored in the repository. + +## Release checklist + +1. Start from an up-to-date `main` branch. +2. Confirm the intended version in `pyproject.toml`. +3. Add the release notes to `CHANGELOG.md`. +4. Run the complete validation suite. +5. Build the wheel and source distribution. +6. Run the distribution-content checker. +7. Install and smoke-test the generated wheel. +8. Merge the release-preparation pull request. +9. Create and push an annotated version tag. +10. Confirm that the Release workflow succeeds. +11. Confirm the package appears on PyPI. +12. Confirm the GitHub release contains both distribution files. + +## Local release validation + +```bash +uv sync --frozen +uv run ruff format --check . +uv run ruff check . +uv run mypy +uv run pytest +git diff --check +``` + +Build fresh distributions: + +```bash +rm -rf dist +uv build --no-sources +uv run python scripts/check_distribution.py +``` + +Smoke-test the built wheel in an isolated environment: + +```bash +rm -rf .release-venv + +uv venv --python 3.11 .release-venv + +uv pip install --python .release-venv/bin/python dist/*.whl + +.release-venv/bin/runbookproof --version +.release-venv/bin/runbookproof rules --format json +.release-venv/bin/runbookproof scan README.md + +rm -rf .release-venv +``` + +## Creating a release + +For version `0.1.0`: + +```bash +git switch main +git pull origin main + +git tag -a v0.1.0 -m "RunbookProof v0.1.0" + +git push origin v0.1.0 +``` + +The tag must exactly match the project version prefixed with `v`. + +The release workflow will: + +1. run formatting, linting, type checking, and tests +2. verify that the tag matches `pyproject.toml` +3. build the source distribution and wheel +4. inspect the distribution contents +5. install and smoke-test the wheel +6. publish the distributions through PyPI Trusted Publishing +7. create a GitHub release and attach the distributions diff --git a/pyproject.toml b/pyproject.toml index f358661..48216bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ runbookproof = "runbookproof.cli:main" Homepage = "https://github.com/antonisloukis/runbookproof" Repository = "https://github.com/antonisloukis/runbookproof" Issues = "https://github.com/antonisloukis/runbookproof/issues" +Changelog = "https://github.com/antonisloukis/runbookproof/blob/main/CHANGELOG.md" +Documentation = "https://github.com/antonisloukis/runbookproof/tree/main/docs" [build-system] requires = ["uv_build>=0.11.32,<0.12.0"] diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py new file mode 100644 index 0000000..a15daad --- /dev/null +++ b/scripts/check_distribution.py @@ -0,0 +1,158 @@ +"""Validate RunbookProof wheel and source-distribution contents.""" + +from __future__ import annotations + +import tarfile +import tomllib +import zipfile +from email.parser import Parser +from pathlib import Path +from typing import cast + + +def _project_identity() -> tuple[str, str]: + """Return the configured project name and version.""" + data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + project = cast(dict[str, object], data["project"]) + + name = project.get("name") + version = project.get("version") + + if not isinstance(name, str): + raise SystemExit("pyproject.toml project.name must be a string.") + + if not isinstance(version, str): + raise SystemExit("pyproject.toml project.version must be a string.") + + return name, version + + +def _single_artifact( + directory: Path, + pattern: str, +) -> Path: + """Return exactly one matching distribution artifact.""" + matches = sorted(directory.glob(pattern)) + + if len(matches) != 1: + raise SystemExit( + f"Expected exactly one {pattern!r} artifact, found {len(matches)}." + ) + + return matches[0] + + +def _verify_wheel( + wheel_path: Path, + project_name: str, + project_version: str, +) -> None: + """Verify package files and wheel metadata.""" + with zipfile.ZipFile(wheel_path) as archive: + names = set(archive.namelist()) + + required_files = { + "runbookproof/__init__.py", + "runbookproof/__main__.py", + "runbookproof/cli.py", + "runbookproof/rules.py", + "runbookproof/py.typed", + } + + missing_files = sorted(required_files - names) + + if missing_files: + raise SystemExit( + "Wheel is missing package files: " + ", ".join(missing_files) + ) + + metadata_paths = [ + name for name in names if name.endswith(".dist-info/METADATA") + ] + entry_point_paths = [ + name for name in names if name.endswith(".dist-info/entry_points.txt") + ] + + if len(metadata_paths) != 1: + raise SystemExit("Wheel must contain exactly one METADATA file.") + + if len(entry_point_paths) != 1: + raise SystemExit("Wheel must contain exactly one entry_points.txt file.") + + metadata_text = archive.read(metadata_paths[0]).decode("utf-8") + metadata = Parser().parsestr(metadata_text) + + if metadata["Name"] != project_name: + raise SystemExit("Wheel project name does not match pyproject.toml.") + + if metadata["Version"] != project_version: + raise SystemExit("Wheel version does not match pyproject.toml.") + + entry_points = archive.read(entry_point_paths[0]).decode("utf-8") + + expected_entry_point = "runbookproof = runbookproof.cli:main" + + if expected_entry_point not in entry_points: + raise SystemExit("Wheel does not contain the RunbookProof CLI entry point.") + + +def _verify_source_distribution( + source_path: Path, +) -> None: + """Verify required source-distribution files.""" + with tarfile.open(source_path, "r:gz") as archive: + names = {member.name for member in archive.getmembers()} + + required_suffixes = ( + "/pyproject.toml", + "/README.md", + "/LICENSE", + "/src/runbookproof/__init__.py", + "/src/runbookproof/__main__.py", + "/src/runbookproof/cli.py", + "/src/runbookproof/rules.py", + "/src/runbookproof/py.typed", + ) + + missing_suffixes = [ + suffix + for suffix in required_suffixes + if not any(name.endswith(suffix) for name in names) + ] + + if missing_suffixes: + raise SystemExit( + "Source distribution is missing files ending with: " + + ", ".join(missing_suffixes) + ) + + +def main() -> None: + """Validate the distributions in the dist directory.""" + project_name, project_version = _project_identity() + dist_directory = Path("dist") + + if not dist_directory.is_dir(): + raise SystemExit("dist directory does not exist. Run uv build first.") + + wheel_path = _single_artifact( + dist_directory, + "*.whl", + ) + source_path = _single_artifact( + dist_directory, + "*.tar.gz", + ) + + _verify_wheel( + wheel_path, + project_name, + project_version, + ) + _verify_source_distribution(source_path) + + print(f"Validated distributions: {wheel_path.name}, {source_path.name}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py new file mode 100644 index 0000000..34ef859 --- /dev/null +++ b/tests/test_release_metadata.py @@ -0,0 +1,55 @@ +"""Tests for release metadata and automation.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path +from typing import cast + + +def _project_version() -> str: + """Return the current project version.""" + data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + project = cast(dict[str, object], data["project"]) + version = project.get("version") + + assert isinstance(version, str) + + return version + + +def test_changelog_contains_current_version() -> None: + """The changelog should document the package version.""" + version = _project_version() + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + + assert f"## [{version}]" in changelog + + +def test_release_documentation_exists() -> None: + """The repository should document the release process.""" + documentation = Path("docs/releasing.md").read_text(encoding="utf-8") + + assert "PyPI Trusted Publisher" in documentation + assert "git tag -a" in documentation + assert "uv build --no-sources" in documentation + + +def test_release_workflow_contains_required_checks() -> None: + """The release workflow should validate before publishing.""" + workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + + required_fragments = ( + "environment: pypi", + "uv run ruff format --check .", + "uv run ruff check .", + "uv run mypy", + "uv run pytest", + "uv build --no-sources", + "scripts/check_distribution.py", + "uv publish", + "gh release create", + ) + + for fragment in required_fragments: + assert fragment in workflow