diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a26d65..df9f946 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,9 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install "Django==${{ matrix.django-version }}" -e .[dev] + # django-rspack is a sibling package and is not published yet. + python -m pip install "django-rspack @ git+https://github.com/shakacode/django-rspack.git@7b09b8baf8d23c822978fcb472672a811927f5e5" + python -m pip install "Django==${{ matrix.django-version }}" -e '.[dev]' - name: Ruff run: ruff check . @@ -53,7 +55,9 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install -e .[dev] + # django-rspack is a sibling package and is not published yet. + python -m pip install "django-rspack @ git+https://github.com/shakacode/django-rspack.git@7b09b8baf8d23c822978fcb472672a811927f5e5" + python -m pip install -e '.[dev]' - name: Install example dependencies working-directory: example diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f719d4f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,196 @@ +name: Release + +on: + workflow_dispatch: + inputs: + repository: + description: Package index to publish to + required: true + default: testpypi + type: choice + options: + - testpypi + - pypi + version: + description: Optional version to validate against package metadata (for example 0.1.0a1) + required: false + type: string + +permissions: + contents: read + +jobs: + unit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.13"] + django-version: ["4.2.*", "5.1.*"] + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + # django-rspack is a sibling package and is not published yet. + python -m pip install "django-rspack @ git+https://github.com/shakacode/django-rspack.git@7b09b8baf8d23c822978fcb472672a811927f5e5" + python -m pip install "Django==${{ matrix.django-version }}" -e '.[dev]' + + - name: Ruff + run: ruff check . + + - name: Pytest + run: pytest + + example-e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: example/package-lock.json + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + # django-rspack is a sibling package and is not published yet. + python -m pip install "django-rspack @ git+https://github.com/shakacode/django-rspack.git@7b09b8baf8d23c822978fcb472672a811927f5e5" + python -m pip install -e '.[dev]' + + - name: Install example dependencies + working-directory: example + run: npm ci + + - name: Install Playwright browser + working-directory: example + run: npx playwright install --with-deps chromium + + - name: Make example scripts executable + run: chmod +x example/bin/dev example/bin/prod example/bin/test-ci + + - name: Run example smoke and Playwright suite + working-directory: example + run: ./bin/test-ci + + build: + needs: + - unit + - example-e2e + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - id: version + name: Read package version + shell: bash + run: | + python - <<'PY' > "$RUNNER_TEMP/package-version" + from pathlib import Path + import re + + text = Path("src/react_on_django/__about__.py").read_text(encoding="utf-8") + match = re.search(r'^__version__ = "([^"]+)"$', text, re.M) + if not match: + raise SystemExit("Could not find __version__ in src/react_on_django/__about__.py") + print(match.group(1)) + PY + version="$(cat "$RUNNER_TEMP/package-version")" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Validate release version + shell: bash + env: + PACKAGE_VERSION: ${{ steps.version.outputs.version }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [[ -n "${INPUT_VERSION}" && "${INPUT_VERSION}" != "${PACKAGE_VERSION}" ]]; then + echo "Requested version ${INPUT_VERSION} does not match package version ${PACKAGE_VERSION}." + exit 1 + fi + + - name: Install build tooling + run: | + python -m pip install --upgrade pip + python -m pip install build twine + + - name: Build distributions + run: python -m build + + - name: Check distribution metadata + run: twine check dist/* + + - name: Store distributions + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-testpypi: + if: inputs.repository == 'testpypi' + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/react-on-django + 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: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + if: inputs.repository == 'pypi' + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/react-on-django + permissions: + id-token: write + + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc68c6..bb3fd17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,5 @@ # Changelog +All notable changes to this project should be documented in this file. + +## [Unreleased] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..da9984f --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: release release-dry-run + +PYTHON ?= python +_truthy = $(filter 1 true yes y on TRUE YES Y ON,$(strip $(1))) + +release: + $(PYTHON) scripts/release.py $(if $(VERSION),--version $(VERSION),) $(if $(REPOSITORY),--repository $(REPOSITORY),) $(if $(call _truthy,$(YES)),--yes,) $(if $(call _truthy,$(SKIP_CHECKS)),--skip-checks,) $(if $(call _truthy,$(SKIP_PUSH)),--skip-push,) + +release-dry-run: + $(PYTHON) scripts/release.py --dry-run $(if $(VERSION),--version $(VERSION),) $(if $(REPOSITORY),--repository $(REPOSITORY),) $(if $(call _truthy,$(YES)),--yes,) $(if $(call _truthy,$(SKIP_CHECKS)),--skip-checks,) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2b72e53 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,200 @@ +# Releasing react-on-django + +The maintainer flow is: + +1. Update `CHANGELOG.md` for the version you want to ship. +2. Run `make release`. +3. Confirm the version, branch, checks, push step, and upload target. +4. The command pushes the branch plus tag before any local `twine` upload. +5. If you did not choose a local upload, use the GitHub workflow manually after + the tag is pushed if you still want CI-based publishing. + +`make release` is the Python equivalent of `rake release`. It validates the +repo state, bumps `src/react_on_django/__about__.py` if needed, runs the +release checks, creates the release commit, tags it, pushes the branch plus tag +to GitHub, and optionally uploads with `twine`. + +## Before you run it + +Start from a clean maintainer checkout. The release command refuses to run if +the worktree has changes outside: + +- `CHANGELOG.md` +- `src/react_on_django/__about__.py` + +Install the maintainer dependencies once: + +```bash +python -m pip install -e '.[dev]' +``` + +If your checkout has multiple local Python entrypoints, point `make` at the +interpreter you want: + +```bash +make release PYTHON=.venv/bin/python3.14 +``` + +`react-on-django` depends on `django-rspack`, so publish the matching +`django-rspack` release to the same package index before publishing +`react-on-django`. PyPI accepts the upload even if dependencies are missing, +but user installs will fail until `django-rspack>=0.1.0` is available. + +## Changelog format + +Add a versioned heading for the release you want to cut. The release command +uses the newest versioned heading in `CHANGELOG.md` unless you pass `VERSION=...` +explicitly. The section must contain actual release notes, not just the +heading. + +Example: + +```md +## [0.1.0a1] - 2026-04-19 + +### Added + +- First alpha release. +``` + +## Release commands + +Normal release: + +```bash +make release +``` + +Explicit version override: + +```bash +make release VERSION=0.1.0a1 +``` + +Release and upload to TestPyPI: + +```bash +make release VERSION=0.1.0a1 REPOSITORY=testpypi +``` + +Release and upload to PyPI: + +```bash +make release VERSION=0.1.0 REPOSITORY=pypi +``` + +Dry run without commit, tag, or push: + +```bash +make release-dry-run VERSION=0.1.0a1 +``` + +Boolean Makefile flags are enabled by truthy values like `1`, `true`, `yes`, +`y`, or `on`. For example: + +```bash +make release-dry-run VERSION=0.1.0a1 YES=1 SKIP_CHECKS=true +``` + +The release command runs: + +- `ruff check .` +- `pytest` +- `cd example && npm ci` +- `cd example && ./bin/test-ci` +- `python -m build --no-isolation` +- `python -m twine check dist/*` + +Stable releases must run from `main`. Prereleases can run from another branch, +but the command will make you confirm that explicitly. + +## Alpha releases + +Use standard PEP 440 prerelease versions: + +- `0.1.0a1` for the first alpha of `0.1.0` +- `0.1.0a2` for the next alpha +- `0.1.0b1` for the first beta +- `0.1.0rc1` for the first release candidate + +`0.10` is not an alpha release. The alpha marker must be part of the version +string, for example `0.10a1` or `0.1.0a1`. + +Each prerelease is immutable. If `0.1.0a1` is bad, publish `0.1.0a2` instead of +trying to replace `0.1.0a1`. + +For local uploads, PyPI does not use a per-release OTP prompt like RubyGems. +Use a PyPI API token in `$HOME/.pypirc`, keyring, or `TWINE_*` environment +variables instead. + +Consumers install prereleases with: + +```bash +python -m pip install --pre react-on-django +``` + +Or pin one directly: + +```bash +python -m pip install react-on-django==0.1.0a1 +``` + +## What `make release` actually does + +1. Resolves the target version from `VERSION=...` or the newest release heading + in `CHANGELOG.md`. +2. Verifies the branch, changelog section, worktree cleanliness, and tag + availability. +3. Updates `src/react_on_django/__about__.py` to the release version. +4. Runs the release checks and local package build. +5. Commits `CHANGELOG.md` and `src/react_on_django/__about__.py` if needed. +6. Creates `vX.Y.Z` or `vX.Y.ZaN`. +7. Pushes the current branch plus tags to `origin`. +8. Optionally uploads `dist/*` with `twine upload --repository pypi|testpypi`. + +The command refuses `SKIP_PUSH=1 REPOSITORY=pypi` or +`SKIP_PUSH=1 REPOSITORY=testpypi` because a package upload should not exist +without the matching commit and tag on GitHub. + +## Local upload setup + +For local `twine` uploads, configure PyPI and TestPyPI credentials once in +`$HOME/.pypirc` or keyring. Example `.pypirc`: + +```ini +[distutils] +index-servers = + pypi + testpypi + +[pypi] +username = __token__ +password = + +[testpypi] +username = __token__ +password = +``` + +## Optional GitHub workflow setup + +`react-on-django` publishes to PyPI through `.github/workflows/release.yml`. +The local release command is now the primary maintainer path. The GitHub +workflow remains available as a manual trusted-publishing fallback. + +Configure trusted publishers for the `react-on-django` project: + +1. In PyPI, open the project and add a publisher for: + - owner: `shakacode` + - repository: `react-on-django` + - workflow: `.github/workflows/release.yml` + - environment: `pypi` +2. In TestPyPI, add the same publisher but use the `testpypi` environment. +3. In GitHub, create `pypi` and `testpypi` environments for this repository. +4. Require manual approval on the `pypi` environment before production + publishes. + +Your PyPI account and 2FA are only used for this website-side setup. Once the +trusted publishers are configured, the release workflow publishes through +PyPI's OIDC flow and does not require a password, API token, or one-time code +for each release. diff --git a/example/bin/dev b/example/bin/dev index 9ce0d3a..1889e58 100755 --- a/example/bin/dev +++ b/example/bin/dev @@ -5,13 +5,40 @@ from __future__ import annotations import argparse import os import signal +import shutil import subprocess import time from pathlib import Path EXAMPLE_DIR = Path(__file__).resolve().parents[1] REPO_DIR = EXAMPLE_DIR.parent -PYTHON = REPO_DIR / ".venv" / "bin" / "python3.14" + + +def resolve_python() -> str: + candidates = ( + os.environ.get("REACT_ON_DJANGO_EXAMPLE_PYTHON"), + str(REPO_DIR / ".venv" / "bin" / "python3.14"), + str(REPO_DIR / ".venv" / "bin" / "python"), + "python3", + ) + for candidate in candidates: + if not candidate: + continue + candidate_path = Path(candidate).expanduser() + if candidate_path.is_absolute() or "/" in candidate: + if candidate_path.exists(): + return str(candidate_path) + continue + resolved = shutil.which(candidate) + if resolved: + return resolved + raise SystemExit( + "Could not find a Python interpreter. " + "Set REACT_ON_DJANGO_EXAMPLE_PYTHON or create .venv/bin/python3.14.", + ) + + +PYTHON = resolve_python() RENDERER_PASSWORD = "react-on-django-example" @@ -213,8 +240,6 @@ def process_specs( def run() -> int: args = parse_args() - if not PYTHON.exists(): - raise SystemExit(f"Expected Python at {PYTHON}") defaults = { "dev": (3000, 3800, 3035), diff --git a/pyproject.toml b/pyproject.toml index 63b08ba..2df7e3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "react-on-django" -version = "0.1.0" +dynamic = ["version"] description = "Render React components from Django templates with SSR, streaming, and RSC support." readme = "README.md" requires-python = ">=3.10" @@ -32,17 +32,29 @@ dependencies = [ "httpcore[http2]>=1.0,<2.0", ] +[project.urls] +Homepage = "https://github.com/shakacode/react-on-django" +Repository = "https://github.com/shakacode/react-on-django" +Issues = "https://github.com/shakacode/react-on-django/issues" +Documentation = "https://github.com/shakacode/react-on-django/tree/main/docs" + [project.optional-dependencies] dev = [ + "build>=1.2,<2.0", + "hatchling>=1.27,<2.0", "pytest>=8.3,<9.0", "pytest-django>=4.9,<5.0", "ruff>=0.11,<0.12", + "twine>=6.1,<7.0", "uvicorn>=0.34,<1.0", ] [tool.hatch.build.targets.wheel] packages = ["src/react_on_django"] +[tool.hatch.version] +path = "src/react_on_django/__about__.py" + [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "tests.settings" pythonpath = [".", "src"] diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..f5e4f26 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,561 @@ +"""Maintainer release command for react-on-django.""" + +from __future__ import annotations + +import argparse +import os +import re +import shlex +import shutil +import subprocess +import sys +from collections.abc import Iterable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VERSION_FILE = REPO_ROOT / "src" / "react_on_django" / "__about__.py" +CHANGELOG_FILE = REPO_ROOT / "CHANGELOG.md" +UPLOAD_REPOSITORIES = ("testpypi", "pypi") +ALLOWED_DIRTY_PATHS = { + "CHANGELOG.md", + "src/react_on_django/__about__.py", +} +VERSION_RE = re.compile( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)" + r"(?:(?P
a|b|rc)(?P\d+))?$"
+)
+CHANGELOG_HEADER_RE = re.compile(r"^##\s+\[(?P[^\]]+)\](?:\s+-\s+.+)?$")
+
+
+class ReleaseError(RuntimeError):
+    """Raised when the release flow cannot continue safely."""
+
+
+@dataclass(frozen=True)
+class ParsedVersion:
+    major: int
+    minor: int
+    patch: int
+    prerelease_label: str | None
+    prerelease_number: int | None
+
+    @property
+    def is_prerelease(self) -> bool:
+        return self.prerelease_label is not None
+
+    @property
+    def tag(self) -> str:
+        return f"v{self}"
+
+    def __str__(self) -> str:
+        base = f"{self.major}.{self.minor}.{self.patch}"
+        if not self.is_prerelease:
+            return base
+        return f"{base}{self.prerelease_label}{self.prerelease_number}"
+
+
+@dataclass(frozen=True)
+class ReleaseContext:
+    branch: str
+    current_version: ParsedVersion
+    target_version: ParsedVersion
+    changelog_dirty: bool
+    version_dirty: bool
+
+
+@dataclass(frozen=True)
+class UploadPlan:
+    repository: str | None
+    source: str
+
+
+def parse_version(value: str) -> ParsedVersion:
+    match = VERSION_RE.match(value.strip())
+    if not match:
+        raise ReleaseError(
+            f"Unsupported version {value!r}. Use X.Y.Z, X.Y.ZaN, X.Y.ZbN, or X.Y.ZrcN."
+        )
+
+    prerelease_number = match.group("pre_n")
+    return ParsedVersion(
+        major=int(match.group("major")),
+        minor=int(match.group("minor")),
+        patch=int(match.group("patch")),
+        prerelease_label=match.group("pre"),
+        prerelease_number=int(prerelease_number) if prerelease_number else None,
+    )
+
+
+def version_sort_key(version: ParsedVersion) -> tuple[int, int, int, int, int]:
+    stage_rank = {None: 3, "rc": 2, "b": 1, "a": 0}[version.prerelease_label]
+    prerelease_number = version.prerelease_number or 0
+    return (version.major, version.minor, version.patch, stage_rank, prerelease_number)
+
+
+def read_current_version() -> ParsedVersion:
+    text = VERSION_FILE.read_text(encoding="utf-8")
+    match = re.search(r'^__version__ = "([^"]+)"$', text, re.M)
+    if not match:
+        raise ReleaseError(f"Could not find __version__ in {VERSION_FILE}")
+    return parse_version(match.group(1))
+
+
+def update_version_file(target_version: ParsedVersion) -> None:
+    text = VERSION_FILE.read_text(encoding="utf-8")
+    updated = re.sub(
+        r'^__version__ = "([^"]+)"$',
+        f'__version__ = "{target_version}"',
+        text,
+        count=1,
+        flags=re.M,
+    )
+    if updated == text:
+        raise ReleaseError(f"Could not update version in {VERSION_FILE}")
+    VERSION_FILE.write_text(updated, encoding="utf-8")
+
+
+@contextmanager
+def prepared_version(
+    target_version: ParsedVersion, *, restore_after_success: bool
+) -> Iterator[None]:
+    original = VERSION_FILE.read_text(encoding="utf-8")
+    current_version = read_current_version()
+    should_update = current_version != target_version
+
+    if should_update:
+        update_version_file(target_version)
+
+    success = False
+    try:
+        yield
+        success = True
+    finally:
+        if should_update and (restore_after_success or not success):
+            VERSION_FILE.write_text(original, encoding="utf-8")
+
+
+def latest_changelog_version() -> ParsedVersion | None:
+    if not CHANGELOG_FILE.exists():
+        return None
+
+    for line in CHANGELOG_FILE.read_text(encoding="utf-8").splitlines():
+        match = CHANGELOG_HEADER_RE.match(line.strip())
+        if not match:
+            continue
+
+        raw_version = match.group("version").strip()
+        if raw_version.lower() == "unreleased":
+            continue
+        return parse_version(raw_version)
+
+    return None
+
+
+def extract_changelog_section(version: ParsedVersion) -> str | None:
+    if not CHANGELOG_FILE.exists():
+        return None
+
+    lines = CHANGELOG_FILE.read_text(encoding="utf-8").splitlines()
+    collecting = False
+    section_lines: list[str] = []
+
+    for line in lines:
+        stripped = line.strip()
+        match = CHANGELOG_HEADER_RE.match(stripped)
+        if match:
+            raw_version = match.group("version").strip()
+            if collecting:
+                break
+            if raw_version == str(version):
+                collecting = True
+            continue
+
+        if collecting:
+            section_lines.append(line)
+
+    if not collecting:
+        return None
+
+    section = "\n".join(section_lines).strip()
+    return section or None
+
+
+def changelog_has_section(version: ParsedVersion) -> bool:
+    return extract_changelog_section(version) is not None
+
+
+def run(
+    *args: str,
+    capture_output: bool = False,
+    check: bool = True,
+    cwd: Path = REPO_ROOT,
+    env: dict[str, str] | None = None,
+) -> str:
+    print(f"$ {shlex.join(args)}", flush=True)
+    completed = subprocess.run(
+        args,
+        cwd=cwd,
+        check=False,
+        text=True,
+        capture_output=capture_output,
+        env=env,
+    )
+    if check and completed.returncode != 0:
+        stderr = completed.stderr.strip() if completed.stderr else ""
+        stdout = completed.stdout.strip() if completed.stdout else ""
+        details = "\n".join(part for part in (stdout, stderr) if part)
+        message = (
+            details or f"Command failed with exit code {completed.returncode}: {shlex.join(args)}"
+        )
+        raise ReleaseError(message)
+    return completed.stdout if capture_output else ""
+
+
+def git_dirty_paths() -> set[str]:
+    output = run("git", "status", "--porcelain=v1", capture_output=True)
+    dirty_paths: set[str] = set()
+    for line in output.splitlines():
+        if not line:
+            continue
+        dirty_paths.add(line[3:])
+    return dirty_paths
+
+
+def current_branch() -> str:
+    return run("git", "branch", "--show-current", capture_output=True).strip()
+
+
+def tag_exists(tag: str) -> bool:
+    result = subprocess.run(
+        ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"],
+        cwd=REPO_ROOT,
+        check=False,
+        text=True,
+        capture_output=True,
+    )
+    return result.returncode == 0
+
+
+def resolve_target_version(
+    requested_version: str | None, current_version: ParsedVersion
+) -> ParsedVersion:
+    if requested_version:
+        return parse_version(requested_version)
+
+    changelog_version = latest_changelog_version()
+    if changelog_version is None:
+        raise ReleaseError(
+            "CHANGELOG.md does not contain a release header yet. Add one like "
+            "`## [0.1.0a1] - 2026-04-19` or pass --version."
+        )
+
+    if version_sort_key(changelog_version) < version_sort_key(current_version):
+        raise ReleaseError(
+            "Latest CHANGELOG version "
+            f"{changelog_version} is older than current package version "
+            f"{current_version}."
+        )
+
+    return changelog_version
+
+
+def prompt_yes_no(question: str, *, default: bool = False) -> bool:
+    if not sys.stdin.isatty():
+        return default
+
+    suffix = " [Y/n] " if default else " [y/N] "
+    response = input(question + suffix).strip().lower()
+    if not response:
+        return default
+    return response in {"y", "yes"}
+
+
+def validate_repo_state(target_version: ParsedVersion) -> ReleaseContext:
+    branch = current_branch()
+    dirty_paths = git_dirty_paths()
+    unexpected = sorted(path for path in dirty_paths if path not in ALLOWED_DIRTY_PATHS)
+    if unexpected:
+        raise ReleaseError(
+            "Release requires a clean worktree except for CHANGELOG.md and "
+            "src/react_on_django/__about__.py.\nUnexpected changes:\n- " + "\n- ".join(unexpected)
+        )
+
+    if not target_version.is_prerelease and branch != "main":
+        raise ReleaseError(f"Stable releases must be run from main. Current branch: {branch}")
+
+    if tag_exists(target_version.tag):
+        raise ReleaseError(f"Git tag {target_version.tag} already exists.")
+
+    if not changelog_has_section(target_version):
+        raise ReleaseError(
+            f"CHANGELOG.md is missing a non-empty section for {target_version}. Add a heading like "
+            f"`## [{target_version}] - YYYY-MM-DD` and describe the release before releasing."
+        )
+
+    current_version = read_current_version()
+    return ReleaseContext(
+        branch=branch,
+        current_version=current_version,
+        target_version=target_version,
+        changelog_dirty="CHANGELOG.md" in dirty_paths,
+        version_dirty="src/react_on_django/__about__.py" in dirty_paths,
+    )
+
+
+def release_check_commands(
+    skip_checks: bool,
+) -> Iterable[tuple[list[str], Path, dict[str, str] | None]]:
+    if skip_checks:
+        return ()
+
+    return (
+        ([sys.executable, "-m", "ruff", "check", "."], REPO_ROOT, None),
+        ([sys.executable, "-m", "pytest"], REPO_ROOT, None),
+        (["npm", "ci"], REPO_ROOT / "example", None),
+        (
+            ["./bin/test-ci"],
+            REPO_ROOT / "example",
+            {
+                **os.environ,
+                "REACT_ON_DJANGO_EXAMPLE_PYTHON": sys.executable,
+            },
+        ),
+    )
+
+
+def run_release_checks(skip_checks: bool) -> None:
+    for command, cwd, env in release_check_commands(skip_checks):
+        run(*command, cwd=cwd, env=env)
+
+
+def build_distributions() -> None:
+    clean_build_artifacts()
+    run(sys.executable, "-m", "build", "--no-isolation")
+    dist_dir = REPO_ROOT / "dist"
+    artifacts = sorted(str(path) for path in dist_dir.glob("*"))
+    if not artifacts:
+        raise ReleaseError("python -m build completed without creating dist artifacts.")
+    run(sys.executable, "-m", "twine", "check", *artifacts)
+
+
+def dist_artifacts() -> list[str]:
+    artifacts = sorted(str(path) for path in (REPO_ROOT / "dist").glob("*"))
+    if not artifacts:
+        raise ReleaseError("No distribution artifacts were found in dist/.")
+    return artifacts
+
+
+def clean_build_artifacts() -> None:
+    dist_dir = REPO_ROOT / "dist"
+    if dist_dir.exists():
+        shutil.rmtree(dist_dir)
+
+
+def parse_repository_name(value: str | None) -> str | None:
+    if value is None:
+        return None
+    normalized = value.strip().lower()
+    if not normalized or normalized == "skip":
+        return None
+    if normalized not in UPLOAD_REPOSITORIES:
+        allowed = ", ".join(("skip",) + UPLOAD_REPOSITORIES)
+        raise ReleaseError(f"Unsupported repository {value!r}. Use one of: {allowed}.")
+    return normalized
+
+
+def prompt_upload_repository() -> UploadPlan:
+    if not sys.stdin.isatty():
+        return UploadPlan(repository=None, source="non-interactive default")
+
+    prompt = "Upload distributions now? [skip/testpypi/pypi] "
+    while True:
+        response = input(prompt).strip().lower()
+        if not response or response == "skip":
+            return UploadPlan(repository=None, source="interactive selection")
+        if response in UPLOAD_REPOSITORIES:
+            return UploadPlan(repository=response, source="interactive selection")
+        print("Enter one of: skip, testpypi, pypi.", flush=True)
+
+
+def resolve_upload_plan(
+    repository: str | None,
+    *,
+    dry_run: bool,
+    yes: bool,
+) -> UploadPlan:
+    selected = parse_repository_name(repository)
+    if dry_run:
+        if selected is not None:
+            print(
+                f"Warning: ignoring --repository {selected} because --dry-run does not upload.",
+                flush=True,
+            )
+        return UploadPlan(repository=None, source="dry-run")
+    if selected is not None:
+        return UploadPlan(repository=selected, source="explicit option")
+    if yes:
+        return UploadPlan(repository=None, source="non-interactive default")
+    return prompt_upload_repository()
+
+
+def validate_upload_push_plan(*, skip_push: bool, upload_plan: UploadPlan) -> None:
+    if skip_push and upload_plan.repository is not None:
+        raise ReleaseError(
+            "Cannot upload distributions with --skip-push. Push the release branch and "
+            "tag before uploading to pypi or testpypi."
+        )
+
+
+def upload_distributions(repository: str) -> None:
+    artifacts = dist_artifacts()
+    run(sys.executable, "-m", "twine", "upload", "--repository", repository, *artifacts)
+
+
+def stage_and_commit_release_files(target_version: ParsedVersion) -> None:
+    run("git", "add", "CHANGELOG.md", "src/react_on_django/__about__.py")
+    diff_status = subprocess.run(
+        ["git", "diff", "--cached", "--quiet"],
+        cwd=REPO_ROOT,
+        check=False,
+    )
+    if diff_status.returncode == 0:
+        return
+    run("git", "commit", "-m", f"Release {target_version}")
+
+
+def create_tag(target_version: ParsedVersion) -> None:
+    run("git", "tag", "-a", target_version.tag, "-m", f"react-on-django {target_version.tag}")
+
+
+def push_release(branch: str) -> None:
+    run("git", "push", "origin", branch, "--follow-tags")
+
+
+def print_release_summary(
+    context: ReleaseContext,
+    *,
+    dry_run: bool,
+    skip_checks: bool,
+    skip_push: bool,
+    upload_plan: UploadPlan,
+) -> None:
+    print("\nRelease summary", flush=True)
+    print(f"  Branch: {context.branch}", flush=True)
+    print(f"  Current version: {context.current_version}", flush=True)
+    print(f"  Target version: {context.target_version}", flush=True)
+    print(f"  Tag: {context.target_version.tag}", flush=True)
+    print(
+        f"  Type: {'prerelease' if context.target_version.is_prerelease else 'stable'}",
+        flush=True,
+    )
+    checks = "skipped" if skip_checks else "ruff, pytest, example/bin/test-ci, build, twine check"
+    print(f"  Checks: {checks}", flush=True)
+    upload = upload_plan.repository or "skip"
+    print(f"  Upload: {upload} ({upload_plan.source})", flush=True)
+    print(f"  Push: {'skipped' if skip_push else 'origin + tag'}", flush=True)
+    print(f"  Mode: {'dry-run' if dry_run else 'live'}", flush=True)
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description="Release react-on-django from a clean maintainer checkout."
+    )
+    parser.add_argument("--version", help="Explicit release version, such as 0.1.0a1.")
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Run checks and build, but do not commit, tag, or push.",
+    )
+    parser.add_argument(
+        "--skip-checks",
+        action="store_true",
+        help="Skip ruff, pytest, and example test execution.",
+    )
+    parser.add_argument(
+        "--skip-push",
+        action="store_true",
+        help="Create the commit and tag locally without pushing.",
+    )
+    parser.add_argument(
+        "--repository",
+        choices=("skip",) + UPLOAD_REPOSITORIES,
+        help="Optional local upload target: pypi, testpypi, or skip.",
+    )
+    parser.add_argument("--yes", action="store_true", help="Skip interactive confirmation prompts.")
+    return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = parse_args(argv)
+
+    try:
+        requested_version = args.version or os.environ.get("VERSION")
+        target_version = resolve_target_version(requested_version, read_current_version())
+        context = validate_repo_state(target_version)
+        upload_plan = resolve_upload_plan(
+            args.repository or os.environ.get("REPOSITORY"),
+            dry_run=args.dry_run,
+            yes=args.yes,
+        )
+        validate_upload_push_plan(skip_push=args.skip_push, upload_plan=upload_plan)
+        print_release_summary(
+            context,
+            dry_run=args.dry_run,
+            skip_checks=args.skip_checks,
+            skip_push=args.skip_push,
+            upload_plan=upload_plan,
+        )
+
+        if context.target_version.is_prerelease and context.branch != "main":
+            if not args.yes and not prompt_yes_no(
+                f"Pre-release {context.target_version} will be cut from {context.branch}. Continue?"
+            ):
+                raise ReleaseError("Aborted before release.")
+
+        if not args.yes and not prompt_yes_no(f"Proceed with release {context.target_version}?"):
+            raise ReleaseError("Aborted before release.")
+
+        try:
+            with prepared_version(context.target_version, restore_after_success=args.dry_run):
+                run_release_checks(args.skip_checks)
+                build_distributions()
+
+                if args.dry_run:
+                    print("\nDry run completed. No commit, tag, or push was created.", flush=True)
+                    return 0
+
+            stage_and_commit_release_files(context.target_version)
+            create_tag(context.target_version)
+            if not args.skip_push:
+                push_release(context.branch)
+            if upload_plan.repository is not None:
+                upload_distributions(upload_plan.repository)
+        finally:
+            clean_build_artifacts()
+
+        print("\nRelease command completed.", flush=True)
+        if upload_plan.repository is not None:
+            print(f"Uploaded distributions to {upload_plan.repository}.", flush=True)
+        if args.skip_push:
+            print(
+                f"Next step: push {context.branch} with {context.target_version.tag} when ready.",
+                flush=True,
+            )
+        elif upload_plan.repository is None:
+            print(
+                "Next step: run the manual GitHub Release workflow or rerun "
+                "with --repository pypi|testpypi if you want local twine upload.",
+                flush=True,
+            )
+        else:
+            print(f"Git refs are pushed for {context.target_version.tag}.", flush=True)
+        return 0
+    except ReleaseError as exc:
+        print(f"\n❌ {exc}", file=sys.stderr, flush=True)
+        return 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/src/react_on_django/__about__.py b/src/react_on_django/__about__.py
new file mode 100644
index 0000000..160c92d
--- /dev/null
+++ b/src/react_on_django/__about__.py
@@ -0,0 +1,3 @@
+"""Package metadata for react-on-django."""
+
+__version__ = "0.1.0a1"
diff --git a/src/react_on_django/__init__.py b/src/react_on_django/__init__.py
index 8c8bce3..331a38e 100644
--- a/src/react_on_django/__init__.py
+++ b/src/react_on_django/__init__.py
@@ -1,7 +1,6 @@
 """Public package exports for React on Django."""
 
-__version__ = "0.1.0"
-
+from .__about__ import __version__
 from .assets import (  # noqa: E402
     get_react_bundle_urls,
     get_server_bundle_path,
@@ -27,6 +26,7 @@
 __all__ = [
     "ComponentMarkup",
     "ReactOnDjangoError",
+    "__version__",
     "get_react_bundle_urls",
     "get_server_bundle_path",
     "get_server_bundle_url",
diff --git a/src/react_on_django/assets.py b/src/react_on_django/assets.py
index f89e7a9..24d07ac 100644
--- a/src/react_on_django/assets.py
+++ b/src/react_on_django/assets.py
@@ -19,11 +19,46 @@
 def _django_rspack_asset_helpers():
     try:
         from django_rspack import get_asset_path, get_asset_url, get_bundle_urls
-    except ImportError as exc:
-        raise ReactOnDjangoError(
-            "django-rspack is required for asset integration. "
-            "Install django-rspack and add 'django_rspack' to INSTALLED_APPS."
-        ) from exc
+    except ImportError:
+        try:
+            from django_rspack.conf import get_config
+            from django_rspack.manifest import get_manifest
+        except ImportError as exc:
+            raise ReactOnDjangoError(
+                "django-rspack is required for asset integration. "
+                "Install django-rspack and add 'django_rspack' to INSTALLED_APPS."
+            ) from exc
+
+        def _build_asset_url(path: str) -> str:
+            asset_host = get_config().asset_host
+            if asset_host:
+                return f"{asset_host.rstrip('/')}{path}"
+            return path
+
+        def get_asset_path(name: str) -> str:
+            return get_manifest().lookup_strict(name)
+
+        def get_asset_url(name: str) -> str:
+            return _build_asset_url(get_asset_path(name))
+
+        def get_bundle_urls(name: str, *, pack_type: str = "js") -> tuple[str, ...]:
+            manifest = get_manifest()
+            chunks = manifest.lookup_pack_with_chunks(name, pack_type=pack_type)
+            if chunks:
+                paths = chunks
+            else:
+                paths = [manifest.lookup_strict(name, pack_type=pack_type)]
+
+            seen: set[str] = set()
+            urls: list[str] = []
+            for path in paths:
+                if path in seen:
+                    continue
+                seen.add(path)
+                urls.append(_build_asset_url(path))
+            return tuple(urls)
+
+        return get_asset_path, get_asset_url, get_bundle_urls
 
     return get_asset_path, get_asset_url, get_bundle_urls
 
diff --git a/tests/test_assets.py b/tests/test_assets.py
index 6a59bf9..6e3e471 100644
--- a/tests/test_assets.py
+++ b/tests/test_assets.py
@@ -1,10 +1,13 @@
 from __future__ import annotations
 
 import json
+import sys
+import types
 
 from django.template import Context, Template
 from django_rspack.manifest import MissingEntryError
 
+import react_on_django.assets as assets
 from react_on_django.assets import (
     get_react_bundle_urls,
     get_server_bundle_path,
@@ -33,6 +36,53 @@ def test_get_react_bundle_urls_returns_chunk_order(settings, tmp_project, sample
     )
 
 
+def test_asset_helpers_fall_back_to_django_rspack_manifest_api(monkeypatch):
+    fake_package = types.ModuleType("django_rspack")
+    fake_package.__path__ = []
+
+    fake_conf = types.ModuleType("django_rspack.conf")
+    fake_manifest_module = types.ModuleType("django_rspack.manifest")
+
+    class FakeConfig:
+        asset_host = "https://cdn.example.com"
+
+    class FakeManifest:
+        def lookup_strict(self, name, pack_type=None):
+            if pack_type:
+                return f"/packs/{name}.{pack_type}"
+            return f"/packs/{name}"
+
+        def lookup_pack_with_chunks(self, name, pack_type=None):
+            if name == "application" and pack_type == "js":
+                return [
+                    "/packs/runtime.js",
+                    "/packs/vendor.js",
+                    "/packs/vendor.js",
+                    "/packs/application.js",
+                ]
+            return None
+
+    fake_conf.get_config = FakeConfig
+    fake_manifest_module.get_manifest = FakeManifest
+
+    monkeypatch.setitem(sys.modules, "django_rspack", fake_package)
+    monkeypatch.setitem(sys.modules, "django_rspack.conf", fake_conf)
+    monkeypatch.setitem(sys.modules, "django_rspack.manifest", fake_manifest_module)
+
+    get_asset_path, get_asset_url, get_bundle_urls = assets._django_rspack_asset_helpers()
+
+    assert get_asset_path("server-bundle.js") == "/packs/server-bundle.js"
+    assert get_asset_url("server-bundle.js") == "https://cdn.example.com/packs/server-bundle.js"
+    assert get_bundle_urls("application", pack_type="js") == (
+        "https://cdn.example.com/packs/runtime.js",
+        "https://cdn.example.com/packs/vendor.js",
+        "https://cdn.example.com/packs/application.js",
+    )
+    assert get_bundle_urls("admin", pack_type="css") == (
+        "https://cdn.example.com/packs/admin.css",
+    )
+
+
 def test_render_react_component_assets_uses_configured_bundle(
     settings,
     tmp_project,
diff --git a/tests/test_release.py b/tests/test_release.py
new file mode 100644
index 0000000..a6c8e20
--- /dev/null
+++ b/tests/test_release.py
@@ -0,0 +1,321 @@
+import importlib.util
+import sys
+from contextlib import contextmanager
+from pathlib import Path
+
+import pytest
+
+
+def load_release_module():
+    module_path = Path(__file__).resolve().parents[1] / "scripts" / "release.py"
+    spec = importlib.util.spec_from_file_location("react_on_django_release", module_path)
+    module = importlib.util.module_from_spec(spec)
+    assert spec.loader is not None
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+def test_parse_version_supports_prereleases():
+    release = load_release_module()
+    parsed = release.parse_version("0.1.0a2")
+
+    assert parsed.major == 0
+    assert parsed.minor == 1
+    assert parsed.patch == 0
+    assert parsed.prerelease_label == "a"
+    assert parsed.prerelease_number == 2
+    assert parsed.is_prerelease is True
+
+
+def test_latest_changelog_version_skips_unreleased(tmp_path, monkeypatch):
+    release = load_release_module()
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text(
+        "# Changelog\n\n## [Unreleased]\n\n## [0.1.0a1] - 2026-04-19\n- First alpha.\n"
+    )
+    monkeypatch.setattr(release, "CHANGELOG_FILE", changelog)
+
+    assert str(release.latest_changelog_version()) == "0.1.0a1"
+
+
+def test_resolve_target_version_uses_changelog_when_version_not_passed(tmp_path, monkeypatch):
+    release = load_release_module()
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text("# Changelog\n\n## [0.1.0a1] - 2026-04-19\n- First alpha.\n")
+    monkeypatch.setattr(release, "CHANGELOG_FILE", changelog)
+
+    current = release.parse_version("0.0.9")
+
+    assert str(release.resolve_target_version(None, current)) == "0.1.0a1"
+
+
+def test_resolve_target_version_requires_changelog_or_explicit_version(tmp_path, monkeypatch):
+    release = load_release_module()
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text("# Changelog\n\n## [Unreleased]\n")
+    monkeypatch.setattr(release, "CHANGELOG_FILE", changelog)
+
+    with pytest.raises(
+        release.ReleaseError,
+        match="CHANGELOG.md does not contain a release header",
+    ):
+        release.resolve_target_version(None, release.parse_version("0.1.0"))
+
+
+def test_changelog_has_section_accepts_second_level_headers(tmp_path, monkeypatch):
+    release = load_release_module()
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text("# Changelog\n\n## [0.1.0a1] - 2026-04-19\n### Added\n- First alpha.\n")
+    monkeypatch.setattr(release, "CHANGELOG_FILE", changelog)
+
+    assert release.changelog_has_section(release.parse_version("0.1.0a1")) is True
+
+
+def test_changelog_has_section_ignores_nested_version_like_headers(tmp_path, monkeypatch):
+    release = load_release_module()
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text(
+        "# Changelog\n\n"
+        "## [0.1.0a1] - 2026-04-19\n"
+        "### [0.1.0] is not a release boundary\n"
+        "- This is still part of the alpha notes.\n"
+    )
+    monkeypatch.setattr(release, "CHANGELOG_FILE", changelog)
+
+    assert release.extract_changelog_section(release.parse_version("0.1.0a1")) == (
+        "### [0.1.0] is not a release boundary\n"
+        "- This is still part of the alpha notes."
+    )
+
+
+def test_changelog_has_section_rejects_empty_release_section(tmp_path, monkeypatch):
+    release = load_release_module()
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text(
+        "# Changelog\n\n## [0.1.0a1] - 2026-04-19\n\n## [0.1.0] - 2026-04-20\n- Stable.\n"
+    )
+    monkeypatch.setattr(release, "CHANGELOG_FILE", changelog)
+
+    assert release.changelog_has_section(release.parse_version("0.1.0a1")) is False
+
+
+def test_extract_changelog_section_returns_release_notes_only(tmp_path, monkeypatch):
+    release = load_release_module()
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text(
+        "# Changelog\n\n"
+        "## [0.1.0a2] - 2026-04-20\n"
+        "### Fixed\n"
+        "- Tightened release checks.\n\n"
+        "## [0.1.0a1] - 2026-04-19\n"
+        "- First alpha.\n"
+    )
+    monkeypatch.setattr(release, "CHANGELOG_FILE", changelog)
+
+    assert release.extract_changelog_section(release.parse_version("0.1.0a2")) == (
+        "### Fixed\n- Tightened release checks."
+    )
+
+
+def test_git_dirty_paths_preserves_leading_status_spaces(monkeypatch):
+    release = load_release_module()
+    monkeypatch.setattr(
+        release,
+        "run",
+        lambda *args, **kwargs: " M CHANGELOG.md\n M src/react_on_django/__about__.py\n",
+    )
+
+    assert release.git_dirty_paths() == {
+        "CHANGELOG.md",
+        "src/react_on_django/__about__.py",
+    }
+
+
+def test_release_check_commands_sets_example_python_for_e2e():
+    release = load_release_module()
+
+    commands = list(release.release_check_commands(skip_checks=False))
+    example_test = next(command for command in commands if command[0] == ["./bin/test-ci"])
+
+    assert example_test[0] == ["./bin/test-ci"]
+    assert example_test[1].name == "example"
+    assert example_test[2]["REACT_ON_DJANGO_EXAMPLE_PYTHON"] == release.sys.executable
+
+
+def test_update_version_file_rewrites_about_module(tmp_path, monkeypatch):
+    release = load_release_module()
+    version_file = tmp_path / "__about__.py"
+    version_file.write_text('__version__ = "0.1.0"\n')
+    monkeypatch.setattr(release, "VERSION_FILE", version_file)
+
+    release.update_version_file(release.parse_version("0.1.0a1"))
+
+    assert version_file.read_text() == '__version__ = "0.1.0a1"\n'
+
+
+def test_prepared_version_restores_after_pre_commit_failure(tmp_path, monkeypatch):
+    release = load_release_module()
+    version_file = tmp_path / "__about__.py"
+    version_file.write_text('__version__ = "0.1.0"\n')
+    monkeypatch.setattr(release, "VERSION_FILE", version_file)
+
+    with pytest.raises(RuntimeError, match="pre-commit failure"):
+        with release.prepared_version(
+            release.parse_version("0.1.0a1"), restore_after_success=False
+        ):
+            raise RuntimeError("pre-commit failure")
+
+    assert version_file.read_text() == '__version__ = "0.1.0"\n'
+
+
+def test_prepared_version_restores_after_keyboard_interrupt(tmp_path, monkeypatch):
+    release = load_release_module()
+    version_file = tmp_path / "__about__.py"
+    version_file.write_text('__version__ = "0.1.0"\n')
+    monkeypatch.setattr(release, "VERSION_FILE", version_file)
+
+    with pytest.raises(KeyboardInterrupt):
+        with release.prepared_version(
+            release.parse_version("0.1.0a1"), restore_after_success=False
+        ):
+            raise KeyboardInterrupt()
+
+    assert version_file.read_text() == '__version__ = "0.1.0"\n'
+
+
+def test_prepared_version_keeps_target_version_after_success(tmp_path, monkeypatch):
+    release = load_release_module()
+    version_file = tmp_path / "__about__.py"
+    version_file.write_text('__version__ = "0.1.0"\n')
+    monkeypatch.setattr(release, "VERSION_FILE", version_file)
+
+    with release.prepared_version(release.parse_version("0.1.0a1"), restore_after_success=False):
+        pass
+
+    assert version_file.read_text() == '__version__ = "0.1.0a1"\n'
+
+
+def test_prepared_version_restores_successful_dry_run(tmp_path, monkeypatch):
+    release = load_release_module()
+    version_file = tmp_path / "__about__.py"
+    version_file.write_text('__version__ = "0.1.0"\n')
+    monkeypatch.setattr(release, "VERSION_FILE", version_file)
+
+    with release.prepared_version(release.parse_version("0.1.0a1"), restore_after_success=True):
+        pass
+
+    assert version_file.read_text() == '__version__ = "0.1.0"\n'
+
+
+def test_version_sort_key_orders_prereleases_before_stable():
+    release = load_release_module()
+    alpha = release.version_sort_key(release.parse_version("0.1.0a1"))
+    beta = release.version_sort_key(release.parse_version("0.1.0b1"))
+    rc = release.version_sort_key(release.parse_version("0.1.0rc1"))
+    stable = release.version_sort_key(release.parse_version("0.1.0"))
+
+    assert alpha < beta < rc < stable
+
+
+def test_parse_repository_name_supports_skip_and_indices():
+    release = load_release_module()
+
+    assert release.parse_repository_name("skip") is None
+    assert release.parse_repository_name("testpypi") == "testpypi"
+    assert release.parse_repository_name("pypi") == "pypi"
+
+
+def test_resolve_upload_plan_defaults_to_skip_when_yes_is_set():
+    release = load_release_module()
+
+    plan = release.resolve_upload_plan(None, dry_run=False, yes=True)
+
+    assert plan.repository is None
+    assert plan.source == "non-interactive default"
+
+
+def test_resolve_upload_plan_warns_when_dry_run_ignores_repository(capsys):
+    release = load_release_module()
+
+    plan = release.resolve_upload_plan("testpypi", dry_run=True, yes=True)
+
+    assert plan.repository is None
+    assert plan.source == "dry-run"
+    assert (
+        "ignoring --repository testpypi because --dry-run does not upload"
+        in capsys.readouterr().out
+    )
+
+
+def test_validate_upload_push_plan_rejects_upload_without_push():
+    release = load_release_module()
+    plan = release.UploadPlan(repository="pypi", source="explicit option")
+
+    with pytest.raises(
+        release.ReleaseError,
+        match="Cannot upload distributions with --skip-push",
+    ):
+        release.validate_upload_push_plan(skip_push=True, upload_plan=plan)
+
+
+def test_main_pushes_release_refs_before_upload(monkeypatch):
+    release = load_release_module()
+    target = release.parse_version("0.1.0a1")
+    current = release.parse_version("0.1.0")
+    calls = []
+
+    @contextmanager
+    def fake_prepared_version(*args, **kwargs):
+        yield
+
+    monkeypatch.setattr(release, "read_current_version", lambda: current)
+    monkeypatch.setattr(
+        release,
+        "resolve_target_version",
+        lambda requested, current_version: target,
+    )
+    monkeypatch.setattr(
+        release,
+        "validate_repo_state",
+        lambda version: release.ReleaseContext(
+            branch="release-branch",
+            current_version=current,
+            target_version=version,
+            changelog_dirty=False,
+            version_dirty=False,
+        ),
+    )
+    monkeypatch.setattr(
+        release,
+        "resolve_upload_plan",
+        lambda repository, dry_run, yes: release.UploadPlan(
+            repository="testpypi", source="explicit option"
+        ),
+    )
+    monkeypatch.setattr(release, "print_release_summary", lambda *args, **kwargs: None)
+    monkeypatch.setattr(release, "prepared_version", fake_prepared_version)
+    monkeypatch.setattr(
+        release,
+        "run_release_checks",
+        lambda skip_checks: calls.append("checks"),
+    )
+    monkeypatch.setattr(release, "build_distributions", lambda: calls.append("build"))
+    monkeypatch.setattr(
+        release,
+        "stage_and_commit_release_files",
+        lambda target_version: calls.append("commit"),
+    )
+    monkeypatch.setattr(release, "create_tag", lambda target_version: calls.append("tag"))
+    monkeypatch.setattr(release, "push_release", lambda branch: calls.append("push"))
+    monkeypatch.setattr(
+        release,
+        "upload_distributions",
+        lambda repository: calls.append("upload"),
+    )
+    monkeypatch.setattr(release, "clean_build_artifacts", lambda: calls.append("clean"))
+
+    exit_code = release.main(["--version", "0.1.0a1", "--repository", "testpypi", "--yes"])
+
+    assert exit_code == 0
+    assert calls == ["checks", "build", "commit", "tag", "push", "upload", "clean"]