Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
@@ -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
116 changes: 116 additions & 0 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
@@ -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"
60 changes: 60 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ docs = [
release = [
"build>=1.2",
"check-wheel-contents>=0.6",
"packaging>=24",
"twine>=6",
]
security = [
Expand All @@ -108,6 +109,7 @@ include = [
"/GOVERNANCE.md",
"/LICENSE",
"/README.md",
"/RELEASING.md",
"/SECURITY.md",
"/SUPPORT.md",
"/docs",
Expand Down
103 changes: 103 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading