From e17a1d8235ec12bf14e71b49539b16c36691daf3 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Tue, 19 May 2026 17:03:26 -0600 Subject: [PATCH] release: add operator-driven PyPI release infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/release.sh (with --test/--help and a RELEASE_DRY_RUN env switch), make release/release-test wrappers, and a pure-shell CHANGELOG block extractor with pytest fixtures. The release flow builds an sdist + py3-none-any wheel via uv build, runs twine check, uploads to PyPI (or TestPyPI), tags the commit, pushes the tag, and creates a matching GitHub Release with both artifacts and the CHANGELOG block as release notes. Refuses dirty trees and missing tokens cleanly; post-twine failures surface PyPI-immutable recovery hints. Bootstraps CHANGELOG.md with the 0.1.0 entry describing the current state of the package. Pins build-system.requires to hatchling>=1.18 for PEP 625 filename normalization. Adds a "Releasing" section to AGENTS.md (CLAUDE.md is a symlink and inherits the change). No CI/workflow files — releases stay operator-driven. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 24 +++++++ CHANGELOG.md | 27 ++++++++ Makefile | 8 ++- pyproject.toml | 2 +- scripts/extract_changelog.sh | 37 ++++++++++ scripts/release.sh | 117 ++++++++++++++++++++++++++++++++ tests/test_extract_changelog.py | 64 +++++++++++++++++ 7 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100755 scripts/extract_changelog.sh create mode 100755 scripts/release.sh create mode 100644 tests/test_extract_changelog.py diff --git a/AGENTS.md b/AGENTS.md index 3fae783..aa06c86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,30 @@ make clean # Remove build artifacts and caches make versions # Show installed package versions ``` +## Releasing + +solstone-linux ships to PyPI via `scripts/release.sh`. The operator runs the +release from a clean checkout; there is no CI publish path. + +```bash +make release-test # upload to TestPyPI (requires TESTPYPI_TOKEN) +make release # upload to PyPI (requires PYPI_TOKEN) +``` + +The script refuses to run on a dirty tree, builds an sdist + a +`py3-none-any` wheel with `uv build`, runs `uvx twine check`, uploads, +tags the commit `vX.Y.Z`, pushes the tag, and creates a matching GitHub +Release with the artifacts attached and the CHANGELOG block as release +notes. + +Before releasing, bump the version in BOTH `pyproject.toml` (`[project].version`) +and `src/solstone_linux/__init__.py` (`__version__`) — they must match — and add +a `## [X.Y.Z] - YYYY-MM-DD` block to `CHANGELOG.md`. + +Set `RELEASE_DRY_RUN=1` to walk the full flow without uploading, tagging, +pushing, or publishing a GitHub Release; the build and `twine check` still +run for real. + ## Development Principles - **Simple code.** Prefer plain functions over classes. Use dataclasses for structured data. Only use classes when managing stateful lifecycle (Observer, Screencaster, SyncService, AudioRecorder). diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..30fde51 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to solstone-linux are documented here. +The format is based on Keep a Changelog (https://keepachangelog.com/), +and this project adheres to Semantic Versioning. + +## [0.1.0] - 2026-05-19 + +First public release of solstone-linux — the Linux desktop observer +for your solstone journal. + +solstone-linux runs as a systemd user service in your GNOME Wayland +session. It experiences screen and audio along with you, holds short +segments locally, and uploads them to your journal in the background. + +### Install paths + +- From PyPI: `pipx install --system-site-packages solstone-linux`, + then `solstone-linux install-service` to register the systemd unit. +- From a clone: `git clone` this repo and run `make install-service` + for development or unreleased changes. + +Both paths rely on host packages for PyGObject, GStreamer with the +PipeWire plugin, PipeWire itself, `pactl`, and `xdg-desktop-portal` +with ScreenCast support. PyGObject and the GStreamer bindings ride +along from system site-packages — that is why `--system-site-packages` +matters. diff --git a/Makefile b/Makefile index 29a6f7e..8a69b6d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # solstone-linux Makefile # Standalone Linux desktop observer for solstone -.PHONY: install test test-only format ci clean clean-install versions all bootstrap install-service service-restart service-status service-logs uninstall-service +.PHONY: install test test-only format ci clean clean-install versions all bootstrap install-service service-restart service-status service-logs uninstall-service release release-test # Default target all: install @@ -157,3 +157,9 @@ versions: .installed @echo "" @echo "=== Installed packages ===" @$(UV) pip list | grep -E "^(pytest|ruff|requests|numpy|soundfile|soundcard|dbus-next|PyGObject)" || true + +release: ## Publish solstone-linux to PyPI (production) + @bash scripts/release.sh + +release-test: ## Publish solstone-linux to TestPyPI + @bash scripts/release.sh --test diff --git a/pyproject.toml b/pyproject.toml index dfdd54b..239dfc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,5 +25,5 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.18"] build-backend = "hatchling.build" diff --git a/scripts/extract_changelog.sh b/scripts/extract_changelog.sh new file mode 100755 index 0000000..7e58cc7 --- /dev/null +++ b/scripts/extract_changelog.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc +# +# Extract a single version's block from CHANGELOG.md. +# Usage: extract_changelog.sh [] +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "usage: $(basename "$0") []" >&2 + exit 2 +fi + +VERSION="$1" +CHANGELOG="${2:-CHANGELOG.md}" + +if [[ ! -f "$CHANGELOG" ]]; then + echo "error: $CHANGELOG not found" >&2 + exit 1 +fi + +# Escape regex metacharacters in the version (dots, etc.) for the awk pattern. +ESCAPED=$(printf '%s\n' "$VERSION" | sed 's/[][\\.*^$/]/\\&/g') +AWK_ESCAPED="${ESCAPED//\\/\\\\}" + +OUTPUT=$(awk -v pat="^## \\\\[${AWK_ESCAPED}\\\\]" ' + /^## \[/ { if (seen) exit } + $0 ~ pat { seen=1 } + seen +' "$CHANGELOG") + +if [[ -z "$OUTPUT" ]]; then + echo "error: no CHANGELOG.md entry for version ${VERSION}" >&2 + exit 1 +fi + +printf '%s\n' "$OUTPUT" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..14aabf0 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc +# +# Publish solstone-linux to PyPI (or TestPyPI with --test). +# Builds sdist + py3-none-any wheel, uploads with twine, tags the commit, +# and creates a GitHub release with the artifacts attached. +# +# Required env: PYPI_TOKEN (or TESTPYPI_TOKEN with --test). +# Optional env: RELEASE_DRY_RUN=1 — runs build + twine check, echoes the +# upload/tag/push/release-create commands instead of executing them. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/release.sh [--test] + +Options: + --test Publish to TestPyPI. + -h, --help Show this help. +EOF +} + +TARGET="PyPI" +TOKEN_VAR="PYPI_TOKEN" +REPOSITORY_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --test) + TARGET="TestPyPI" + TOKEN_VAR="TESTPYPI_TOKEN" + REPOSITORY_ARGS=(--repository-url https://test.pypi.org/legacy/) + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +REPO_ROOT=$(git rev-parse --show-toplevel) +cd "$REPO_ROOT" + +if [[ -z "${!TOKEN_VAR:-}" ]]; then + echo "error: $TOKEN_VAR not set (required for $TARGET upload)" >&2 + exit 1 +fi +TOKEN="${!TOKEN_VAR}" + +if ! git diff --quiet HEAD || [[ -n "$(git status --porcelain)" ]]; then + echo "error: working tree dirty; commit or stash changes before releasing" >&2 + exit 1 +fi + +rm -rf dist/ +uv build + +shopt -s nullglob +SDISTS=(dist/solstone_linux-*.tar.gz) +shopt -u nullglob + +if [[ ${#SDISTS[@]} -ne 1 ]]; then + echo "error: expected exactly one solstone_linux sdist in dist/" >&2 + exit 1 +fi + +SDIST_NAME=$(basename "${SDISTS[0]}") +VERSION="${SDIST_NAME#solstone_linux-}" +VERSION="${VERSION%.tar.gz}" + +uvx twine check dist/* + +# Pre-flight: verify the CHANGELOG block exists before publishing. +bash scripts/extract_changelog.sh "$VERSION" >/dev/null + +if [[ -n "${RELEASE_DRY_RUN:-}" ]]; then + RUN=(echo "[dry-run]") +else + RUN=() +fi + +TWINE_USERNAME=__token__ TWINE_PASSWORD="$TOKEN" \ + "${RUN[@]}" uvx twine upload "${REPOSITORY_ARGS[@]}" dist/* + +TAG="v${VERSION}" +"${RUN[@]}" git tag -a "$TAG" -m "solstone-linux ${VERSION}" +if ! "${RUN[@]}" git push origin "$TAG"; then + echo "error: git push origin ${TAG} failed; the tag was created locally but not pushed." >&2 + echo " ${TARGET} is published and immutable. Resolve the push and create the GitHub release manually:" >&2 + echo " gh release create ${TAG} dist/solstone_linux-${VERSION}.tar.gz dist/solstone_linux-${VERSION}-py3-none-any.whl --title 'solstone-linux ${VERSION}' --notes-file " >&2 + exit 1 +fi + +NOTES_FILE=$(mktemp) +trap 'rm -f "$NOTES_FILE"' EXIT +scripts/extract_changelog.sh "$VERSION" > "$NOTES_FILE" + +if ! "${RUN[@]}" gh release create "$TAG" \ + "dist/solstone_linux-${VERSION}.tar.gz" \ + "dist/solstone_linux-${VERSION}-py3-none-any.whl" \ + --title "solstone-linux ${VERSION}" \ + --notes-file "$NOTES_FILE"; then + echo "error: gh release create failed." >&2 + echo " ${TARGET} is published and immutable; the git tag ${TAG} is pushed." >&2 + echo " Re-run manually:" >&2 + echo " gh release create ${TAG} dist/solstone_linux-${VERSION}.tar.gz dist/solstone_linux-${VERSION}-py3-none-any.whl --title 'solstone-linux ${VERSION}' --notes-file " >&2 + exit 1 +fi + +echo "published solstone-linux ${VERSION} to ${TARGET}" diff --git a/tests/test_extract_changelog.py b/tests/test_extract_changelog.py new file mode 100644 index 0000000..8271a26 --- /dev/null +++ b/tests/test_extract_changelog.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import subprocess +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "extract_changelog.sh" + + +def _run(args, cwd): + return subprocess.run( + ["bash", str(SCRIPT), *args], + cwd=cwd, + capture_output=True, + text=True, + ) + + +def test_two_block_extracts_target_only(tmp_path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n" + "\n" + "## [0.2.0] - 2026-06-01\n" + "\n" + "second release line.\n" + "\n" + "## [0.1.0] - 2026-05-19\n" + "\n" + "first release line.\n" + ) + result = _run(["0.2.0", str(changelog)], cwd=tmp_path) + assert result.returncode == 0, result.stderr + assert "## [0.2.0]" in result.stdout + assert "second release line." in result.stdout + assert "## [0.1.0]" not in result.stdout + assert "first release line." not in result.stdout + + +def test_one_block_bootstrap(tmp_path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n" + "\n" + "## [0.1.0] - 2026-05-19\n" + "\n" + "first release line.\n" + "trailing line.\n" + ) + result = _run(["0.1.0", str(changelog)], cwd=tmp_path) + assert result.returncode == 0, result.stderr + assert "## [0.1.0]" in result.stdout + assert "first release line." in result.stdout + assert "trailing line." in result.stdout + + +def test_missing_version_errors(tmp_path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n## [0.1.0] - 2026-05-19\n\nfirst release line.\n" + ) + result = _run(["9.9.9", str(changelog)], cwd=tmp_path) + assert result.returncode != 0 + assert "9.9.9" in result.stderr -- 2.51.2