diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..ef11a25 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,76 @@ +name: Release build + +on: + push: + tags: + - v[0-9]* + +permissions: + contents: read + +env: + UV_FROZEN: "true" + +jobs: + build: + name: Build and attest immutable artifacts + runs-on: ubuntu-latest + permissions: + attestations: write + contents: read + id-token: write + steps: + - name: Check out tagged source + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: 0.12.3 + enable-cache: true + + - name: Install release environment + run: | + uv python install 3.11 + uv sync --group release --group security --python 3.11 + + - name: Validate tag and source version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: uv run --python 3.11 python scripts/release.py "$RELEASE_TAG" + + - name: Build once + run: uv build --out-dir artifact/release + + - name: Validate distributions + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + uv run --python 3.11 python scripts/release.py "$RELEASE_TAG" --artifacts artifact/release + uvx --from twine==7.0.0 twine check artifact/release/* + uvx --from check-wheel-contents==0.6.3 check-wheel-contents artifact/release/*.whl + + - name: Generate dependency evidence and CycloneDX SBOM + run: | + mkdir -p artifact/evidence + uv export --no-dev --no-hashes --no-emit-project --output-file artifact/evidence/requirements.txt + uvx --from pip-audit==2.10.1 pip-audit --requirement artifact/evidence/requirements.txt --format cyclonedx-json --output artifact/evidence/sbom.cdx.json + + - name: Install and import the built wheel in a clean environment + run: | + python -m venv "$RUNNER_TEMP/urbanpy-wheel-smoke" + "$RUNNER_TEMP/urbanpy-wheel-smoke/bin/python" -m pip install artifact/release/*.whl + "$RUNNER_TEMP/urbanpy-wheel-smoke/bin/python" -c "import urbanpy; print(urbanpy.__version__)" + + - name: Attest package provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: artifact/release/* + + - name: Upload immutable release bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: urbanpy-release-${{ github.ref_name }} + path: artifact + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..9b67c4e --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,116 @@ +name: Release publish + +on: + workflow_dispatch: + inputs: + build_run_id: + description: Successful Release build workflow run ID + required: true + type: string + version: + description: Exact package version without the v prefix + required: true + type: string + target: + description: Protected trusted-publishing destination + required: true + type: choice + options: + - testpypi + - pypi + +permissions: + contents: read + +env: + UV_FROZEN: "true" + +jobs: + publish: + name: Publish reviewed artifacts to ${{ inputs.target }} + runs-on: ubuntu-latest + environment: release-${{ inputs.target }} + permissions: + actions: read + contents: read + id-token: write + steps: + - name: Check out the exact release tag + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: v${{ inputs.version }} + + - name: Verify source workflow and commit + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ inputs.build_run_id }} + run: | + run_json="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID")" + test "$(jq -r .conclusion <<<"$run_json")" = "success" + test "$(jq -r .event <<<"$run_json")" = "push" + test "$(jq -r .path <<<"$run_json")" = ".github/workflows/release-build.yml" + test "$(jq -r .head_repository.full_name <<<"$run_json")" = "$GITHUB_REPOSITORY" + test "$(jq -r .head_sha <<<"$run_json")" = "$(git rev-parse HEAD)" + + - name: Download the immutable build bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: urbanpy-release-v${{ inputs.version }} + path: artifact + run-id: ${{ inputs.build_run_id }} + github-token: ${{ github.token }} + + - name: Install validation environment + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: 0.12.3 + + - name: Sync locked release tools + run: | + uv python install 3.11 + uv sync --group release --python 3.11 + + - name: Revalidate tag, target, and artifact contents + env: + RELEASE_TAG: v${{ inputs.version }} + RELEASE_TARGET: ${{ inputs.target }} + run: uv run --python 3.11 python scripts/release.py "$RELEASE_TAG" --target "$RELEASE_TARGET" --artifacts artifact/release + + - name: Publish prerelease to TestPyPI with OIDC + if: inputs.target == 'testpypi' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: artifact/release + repository-url: https://test.pypi.org/legacy/ + verbose: true + + - name: Publish stable release to PyPI with OIDC + if: inputs.target == 'pypi' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: artifact/release + verbose: true + + github-release: + name: Publish GitHub release after PyPI + if: inputs.target == 'pypi' + needs: publish + runs-on: ubuntu-latest + environment: release-pypi + permissions: + actions: read + contents: write + steps: + - name: Download the published build bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: urbanpy-release-v${{ inputs.version }} + path: artifact + run-id: ${{ inputs.build_run_id }} + github-token: ${{ github.token }} + + - name: Create release from the existing protected tag + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: v${{ inputs.version }} + run: gh release create "$RELEASE_TAG" artifact/release/* --verify-tag --generate-notes --title "UrbanPy $RELEASE_TAG" diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..208c59f --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,60 @@ +# Release runbook + +Only a human maintainer may tag, approve, publish, yank, or release UrbanPy. +Automation builds and verifies artifacts but never chooses to release them. + +## One-time trusted-publishing setup + +Repository and PyPI administrators must create protected GitHub environments +named `release-testpypi` and `release-pypi`, require designated maintainer +reviewers, prevent self-review, and restrict deployment to protected `v*` tags. +Configure matching trusted publishers on TestPyPI and PyPI for +`release-publish.yml` and the exact environment name. Do not add API tokens. + +Protect release tags and require the same human review, CI, mandatory SonarQube, +security, and dependency-compliance gates used for the default branch. These are +administrator actions and are intentionally not performed by coding agents. + +## Prerelease procedure + +1. Reconcile `CHANGELOG.md`, version metadata, deprecations, and migration docs. +2. Confirm supported Python CI, docs, package, vulnerability, license, and + mandatory SonarQube checks are green on the reviewed commit. +3. Set an alpha, beta, or release-candidate version and merge through normal + review. A maintainer creates the matching protected tag, such as `v0.3.0rc1`. +4. `release-build.yml` validates the tag, builds once, inspects the artifacts, + creates a CycloneDX SBOM, runs a clean install, attests provenance, and stores + one immutable bundle. +5. Inspect the build log, SBOM, provenance, wheel/sdist, and artifact run ID. +6. Dispatch `release-publish.yml` with that run ID, exact version, and + `testpypi`. A protected-environment reviewer approves OIDC publication. +7. Install from TestPyPI in a clean environment and exercise imports, a local + geometry workflow, canonical Geofabrik resolution, and the documented OSRM + smoke test on each claimed platform. + +The workflow rejects prerelease versions sent to production PyPI. + +## Stable release procedure + +Repeat the review with a stable version and protected tag. Dispatch publish with +target `pypi`. The exact already-built artifacts are promoted through OIDC; no +build occurs in the publish job. After PyPI succeeds, the workflow creates the +GitHub release from the same bundle and existing tag. + +Deploy versioned documentation from the released version only after package +publication and smoke tests succeed. Submit a conda-forge recipe after the stable +PyPI artifact has community validation; do not maintain a private Conda publish +workflow. + +## Failure, rollback, and post-release + +- Before publication, abandon the tag and create a corrected version; never + replace artifacts under an existing version. +- After publication, yank only for security, data-loss, installation, or severe + correctness defects. Publish a fixed patch; do not silently overwrite files. +- Use a private security advisory and coordinated disclosure for vulnerabilities. +- Record the incident, affected versions, mitigations, and upgrade path in the + changelog and GitHub release. +- For four weeks, monitor installation failures, provider contracts, security + reports, dependency alerts, and OSRM platform evidence. Assign an accountable + maintainer before starting the release. diff --git a/pyproject.toml b/pyproject.toml index b3d81ef..4b60854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ docs = [ release = [ "build>=1.2", "check-wheel-contents>=0.6", + "packaging>=24", "twine>=6", ] security = [ @@ -108,6 +109,7 @@ include = [ "/GOVERNANCE.md", "/LICENSE", "/README.md", + "/RELEASING.md", "/SECURITY.md", "/SUPPORT.md", "/docs", diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..e2554f2 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,103 @@ +"""Validate immutable UrbanPy release inputs without mutating source files.""" + +from __future__ import annotations + +import argparse +import email.parser +import sys +import tarfile +import tomllib +import zipfile +from pathlib import Path + +from packaging.version import Version + + +class ReleaseValidationError(ValueError): + """A release input is inconsistent or unsafe to publish.""" + + +def project_version(pyproject: Path = Path("pyproject.toml")) -> Version: + with pyproject.open("rb") as source: + value = tomllib.load(source)["project"]["version"] + return Version(value) + + +def validate_tag(tag: str, *, pyproject: Path = Path("pyproject.toml")) -> Version: + if not tag.startswith("v"): + raise ReleaseValidationError("release tags must start with 'v'") + tagged = Version(tag[1:]) + declared = project_version(pyproject) + if tagged != declared: + raise ReleaseValidationError( + f"tag version {tagged} does not match project version {declared}" + ) + return declared + + +def validate_artifacts(directory: Path, expected: Version) -> None: + wheels = sorted(directory.glob("urbanpy-*.whl")) + sdists = sorted(directory.glob("urbanpy-*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise ReleaseValidationError("expected exactly one UrbanPy wheel and one sdist") + wheel_version = _wheel_version(wheels[0]) + sdist_version = _sdist_version(sdists[0]) + if wheel_version != expected or sdist_version != expected: + raise ReleaseValidationError( + "wheel, sdist, and requested release versions must match" + ) + + +def validate_target(version: Version, target: str) -> None: + if target not in {"pypi", "testpypi"}: + raise ReleaseValidationError("target must be pypi or testpypi") + if target == "pypi" and (version.is_prerelease or version.is_devrelease): + raise ReleaseValidationError("prereleases must be exercised on TestPyPI") + + +def _wheel_version(path: Path) -> Version: + with zipfile.ZipFile(path) as archive: + metadata_names = [ + name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + ] + if len(metadata_names) != 1: + raise ReleaseValidationError("wheel must contain exactly one METADATA file") + message = email.parser.BytesParser().parsebytes(archive.read(metadata_names[0])) + if message.get("Name") != "urbanpy": + raise ReleaseValidationError("wheel project name is not urbanpy") + return Version(message["Version"]) + + +def _sdist_version(path: Path) -> Version: + with tarfile.open(path, "r:gz") as archive: + roots = {name.split("/", 1)[0] for name in archive.getnames() if name} + if len(roots) != 1: + raise ReleaseValidationError("sdist must contain exactly one root directory") + root = roots.pop() + prefix = "urbanpy-" + if not root.startswith(prefix): + raise ReleaseValidationError("sdist root must use the UrbanPy project name") + return Version(root.removeprefix(prefix)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("tag") + parser.add_argument("--artifacts", type=Path) + parser.add_argument("--target", choices=("pypi", "testpypi")) + args = parser.parse_args(argv) + try: + version = validate_tag(args.tag) + if args.artifacts: + validate_artifacts(args.artifacts, version) + if args.target: + validate_target(version, args.target) + except (ReleaseValidationError, OSError, KeyError) as error: + print(f"release validation failed: {error}", file=sys.stderr) + return 2 + print(f"validated UrbanPy {version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release_test.py b/tests/release_test.py new file mode 100644 index 0000000..dfdea0d --- /dev/null +++ b/tests/release_test.py @@ -0,0 +1,53 @@ +import tarfile +import zipfile +from pathlib import Path + +import pytest +from packaging.version import Version + +from scripts.release import ( + ReleaseValidationError, + validate_artifacts, + validate_tag, + validate_target, +) + + +def _pyproject(path: Path, version: str) -> Path: + path.write_text(f'[project]\nname = "urbanpy"\nversion = "{version}"\n') + return path + + +def test_tag_must_exactly_match_declared_version(tmp_path): + pyproject = _pyproject(tmp_path / "pyproject.toml", "0.3.0a1") + + assert validate_tag("v0.3.0a1", pyproject=pyproject) == Version("0.3.0a1") + with pytest.raises(ReleaseValidationError, match="does not match"): + validate_tag("v0.3.0a2", pyproject=pyproject) + with pytest.raises(ReleaseValidationError, match="start with"): + validate_tag("0.3.0a1", pyproject=pyproject) + + +def test_prereleases_cannot_publish_to_production_pypi(): + validate_target(Version("0.3.0a1"), "testpypi") + validate_target(Version("0.3.0"), "pypi") + with pytest.raises(ReleaseValidationError, match="TestPyPI"): + validate_target(Version("0.3.0rc1"), "pypi") + + +def test_artifact_versions_are_read_from_contents_not_filenames(tmp_path): + wheel = tmp_path / "urbanpy-0.3.0-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "urbanpy-0.3.0.dist-info/METADATA", + "Metadata-Version: 2.4\nName: urbanpy\nVersion: 0.3.0\n", + ) + sdist = tmp_path / "urbanpy-0.3.0.tar.gz" + with tarfile.open(sdist, "w:gz") as archive: + placeholder = tmp_path / "pyproject.toml" + placeholder.write_text("[project]\n") + archive.add(placeholder, arcname="urbanpy-0.3.0/pyproject.toml") + + validate_artifacts(tmp_path, Version("0.3.0")) + with pytest.raises(ReleaseValidationError, match="must match"): + validate_artifacts(tmp_path, Version("0.3.1")) diff --git a/uv.lock b/uv.lock index ec622eb..62b4a03 100644 --- a/uv.lock +++ b/uv.lock @@ -3476,6 +3476,7 @@ lint = [ release = [ { name = "build" }, { name = "check-wheel-contents" }, + { name = "packaging" }, { name = "twine" }, ] security = [ @@ -3538,6 +3539,7 @@ lint = [ release = [ { name = "build", specifier = ">=1.2" }, { name = "check-wheel-contents", specifier = ">=0.6" }, + { name = "packaging", specifier = ">=24" }, { name = "twine", specifier = ">=6" }, ] security = [{ name = "pip-audit", specifier = ">=2.7" }]