From bfabcd2ce14008570f8cfdc0ff357f20638ed8e8 Mon Sep 17 00:00:00 2001 From: Michael Emperador Date: Sat, 15 Aug 2026 20:51:51 -0400 Subject: [PATCH] feat(release): automate GitHub releases --- .github/workflows/python.yml | 16 +- .github/workflows/release.yml | 279 ++++++++++++++++++++++++++++++ .gitignore | 5 +- MANIFEST.in | 2 + pyproject.toml | 26 +++ tests/test_release_tools.py | 90 ++++++++++ tools/__init__.py | 1 + tools/release_policy.py | 71 ++++++++ tools/verify_release_artifacts.py | 99 +++++++++++ 9 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 MANIFEST.in create mode 100644 tests/test_release_tools.py create mode 100644 tools/__init__.py create mode 100644 tools/release_policy.py create mode 100644 tools/verify_release_artifacts.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 911a072..697ca70 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -9,9 +9,23 @@ permissions: concurrency: group: python-ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + release-policy: + name: Release policy + if: github.event_name == 'pull_request' && github.base_ref == 'master' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Validate the pull request title + env: + SMALL_OS_PR_TITLE: ${{ github.event.pull_request.title }} + run: python tools/release_policy.py + typing: name: Static typing runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..33b26a0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,279 @@ +name: Release + +on: + workflow_run: + workflows: ["Python CI"] + types: [completed] + branches: [master] + workflow_dispatch: + inputs: + tag: + description: Existing vX.Y.Z tag whose GitHub Release assets need repair + required: true + type: string + +permissions: + contents: read + +concurrency: + group: smallos-release + cancel-in-progress: false + +jobs: + release: + name: Tag, build, and publish GitHub Release + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'master' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Check out the exact CI-tested commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Bind the tested commit to the local release branch + env: + SMALL_OS_RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + git checkout --force -B master "$SMALL_OS_RELEASE_SHA" + test "$(git rev-parse HEAD)" = "$SMALL_OS_RELEASE_SHA" + + - name: Detect an idempotent workflow rerun + id: existing + run: | + exact_tags="$(git tag --points-at HEAD --list 'v[0-9]*')" + tag_count="$(printf '%s\n' "$exact_tags" | sed '/^$/d' | wc -l | tr -d ' ')" + if [ "$tag_count" -gt 1 ]; then + echo "Expected at most one release tag on the tested commit" >&2 + exit 1 + fi + existing_tag="$(printf '%s\n' "$exact_tags" | sed '/^$/d')" + echo "tag=$existing_tag" >> "$GITHUB_OUTPUT" + + - name: Verify an existing release is already complete + if: steps.existing.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + SMALL_OS_EXISTING_TAG: ${{ steps.existing.outputs.tag }} + run: | + asset_state="$(gh release view "$SMALL_OS_EXISTING_TAG" --json assets --jq ' + (.assets | map(select(.name | endswith(".whl"))) | length) == 1 and + (.assets | map(select(.name | endswith(".tar.gz"))) | length) == 1 + ')" + if [ "$asset_state" != "true" ]; then + echo "Existing release is incomplete; use the manual repair workflow" >&2 + exit 1 + fi + + - name: Determine the semantic version and create the tag and release + id: semantic-release + if: steps.existing.outputs.tag == '' + uses: python-semantic-release/python-semantic-release@39dd2052f2ce8282a5d932c31d58a2ca06d2550e # v10.6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + build: "false" + changelog: "false" + commit: "false" + push: "true" + strict: "true" + tag: "true" + vcs_release: "true" + + - name: Resolve release metadata + id: metadata + env: + SMALL_OS_EXISTING_TAG: ${{ steps.existing.outputs.tag }} + SMALL_OS_RELEASED: ${{ steps.semantic-release.outputs.released }} + SMALL_OS_RELEASE_TAG: ${{ steps.semantic-release.outputs.tag }} + SMALL_OS_RELEASE_VERSION: ${{ steps.semantic-release.outputs.version }} + run: | + if [ -n "$SMALL_OS_EXISTING_TAG" ]; then + release_tag="$SMALL_OS_EXISTING_TAG" + release_version="${SMALL_OS_EXISTING_TAG#v}" + new_release=false + elif [ "$SMALL_OS_RELEASED" = "true" ]; then + release_tag="$SMALL_OS_RELEASE_TAG" + release_version="$SMALL_OS_RELEASE_VERSION" + new_release=true + else + echo "Successful master CI did not produce a semantic release" >&2 + exit 1 + fi + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "version=$release_version" >> "$GITHUB_OUTPUT" + echo "new_release=$new_release" >> "$GITHUB_OUTPUT" + + - name: Set up Python + if: steps.metadata.outputs.new_release == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + + - name: Build distributions from the tagged commit + if: steps.metadata.outputs.new_release == 'true' + run: | + python -m pip install build + python -m build + + - name: Verify release artifact metadata + if: steps.metadata.outputs.new_release == 'true' + env: + SMALL_OS_RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + run: >- + python tools/verify_release_artifacts.py dist + --name SmallPackage + --version "$SMALL_OS_RELEASE_VERSION" + + - name: Install the release wheel + if: steps.metadata.outputs.new_release == 'true' + run: python -m pip install --force-reinstall dist/*.whl + + - name: Smoke-test the installed release outside the checkout + if: steps.metadata.outputs.new_release == 'true' + working-directory: /tmp + env: + SMALL_OS_RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + run: >- + python -c "import os; from importlib.metadata import version; + from SmallPackage import SmallOS, SmallOSConfig, SmallTask, Unix; + assert version('SmallPackage') == os.environ['SMALL_OS_RELEASE_VERSION']; + assert SmallOS(config=SmallOSConfig()).setKernel(Unix())" + + - name: Attach distributions to the GitHub Release + if: steps.metadata.outputs.new_release == 'true' + uses: python-semantic-release/publish-action@5a5718ce47b892ef699f2972dae122297771d641 # v10.6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.metadata.outputs.tag }} + + - name: Retain release distributions + if: steps.metadata.outputs.new_release == 'true' + uses: actions/upload-artifact@v4 + with: + name: python-release-${{ steps.metadata.outputs.version }} + path: dist/ + if-no-files-found: error + + - name: Write release summary + env: + SMALL_OS_RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + SMALL_OS_RELEASE_TAG: ${{ steps.metadata.outputs.tag }} + SMALL_OS_RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + SMALL_OS_NEW_RELEASE: ${{ steps.metadata.outputs.new_release }} + run: | + { + echo "## SmallOS release" + echo "" + echo "- Commit: \`$SMALL_OS_RELEASE_SHA\`" + echo "- Tag: \`$SMALL_OS_RELEASE_TAG\`" + echo "- Version: \`$SMALL_OS_RELEASE_VERSION\`" + echo "- Created by this run: \`$SMALL_OS_NEW_RELEASE\`" + } >> "$GITHUB_STEP_SUMMARY" + + repair: + name: Repair assets for an existing release + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out repository history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Preserve the trusted release verifier + run: cp tools/verify_release_artifacts.py "$RUNNER_TEMP/verify_release_artifacts.py" + + - name: Validate and check out the immutable release tag + id: metadata + env: + SMALL_OS_REPAIR_TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + if ! printf '%s\n' "$SMALL_OS_REPAIR_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Repair tag must have the form vX.Y.Z" >&2 + exit 1 + fi + git rev-parse --verify "refs/tags/${SMALL_OS_REPAIR_TAG}^{commit}" >/dev/null + git checkout --detach "refs/tags/$SMALL_OS_REPAIR_TAG" + gh release view "$SMALL_OS_REPAIR_TAG" >/dev/null + echo "tag=$SMALL_OS_REPAIR_TAG" >> "$GITHUB_OUTPUT" + echo "version=${SMALL_OS_REPAIR_TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + + - name: Rebuild distributions from the immutable tag + run: | + python -m pip install build + python -m build + + - name: Verify rebuilt artifact metadata + env: + SMALL_OS_RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + run: >- + python "$RUNNER_TEMP/verify_release_artifacts.py" dist + --name SmallPackage + --version "$SMALL_OS_RELEASE_VERSION" + + - name: Install the rebuilt wheel + run: python -m pip install --force-reinstall dist/*.whl + + - name: Smoke-test the rebuilt wheel outside the checkout + working-directory: /tmp + env: + SMALL_OS_RELEASE_VERSION: ${{ steps.metadata.outputs.version }} + run: >- + python -c "import os; from importlib.metadata import version; + from SmallPackage import SmallOS, SmallOSConfig, SmallTask, Unix; + assert version('SmallPackage') == os.environ['SMALL_OS_RELEASE_VERSION']; + assert SmallOS(config=SmallOSConfig()).setKernel(Unix())" + + - name: Attach only missing assets + env: + GH_TOKEN: ${{ github.token }} + SMALL_OS_REPAIR_TAG: ${{ steps.metadata.outputs.tag }} + run: | + existing_assets="$RUNNER_TEMP/smallos-existing-release-assets.txt" + gh release view "$SMALL_OS_REPAIR_TAG" --json assets --jq '.assets[].name' > "$existing_assets" + for asset_path in dist/*; do + asset_name="$(basename "$asset_path")" + if grep -Fqx "$asset_name" "$existing_assets"; then + echo "Keeping existing release asset: $asset_name" + else + gh release upload "$SMALL_OS_REPAIR_TAG" "$asset_path" + fi + done + + - name: Retain repaired distributions + uses: actions/upload-artifact@v4 + with: + name: python-release-repair-${{ steps.metadata.outputs.version }} + path: dist/ + if-no-files-found: error + + - name: Write repair summary + env: + SMALL_OS_REPAIR_TAG: ${{ steps.metadata.outputs.tag }} + SMALL_OS_REPAIR_VERSION: ${{ steps.metadata.outputs.version }} + run: | + { + echo "## SmallOS release repair" + echo "" + echo "- Existing tag: \`$SMALL_OS_REPAIR_TAG\`" + echo "- Version: \`$SMALL_OS_REPAIR_VERSION\`" + echo "- Result: rebuilt, verified, and uploaded missing assets only" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 659e075..d54f572 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ htmlcov/ build/ dist/ *.egg-info/ - +docs/ +skills/ +AGENTS.md +CLAUDE.md diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..29cc445 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include demos *.py +recursive-include tests/typing *.py diff --git a/pyproject.toml b/pyproject.toml index 3df95ae..f4d7326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,32 @@ SmallPackage = ["py.typed"] [tool.setuptools_scm] fallback_version = "0.0.0" +[tool.semantic_release] +commit_parser = "conventional" +tag_format = "v{version}" +version_toml = [] +version_variables = [] + +[tool.semantic_release.branches.master] +match = "master" +prerelease = false + +[tool.semantic_release.commit_parser_options] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] +other_allowed_tags = [ + "build", + "chore", + "ci", + "docs", + "refactor", + "style", + "test", +] +default_bump_level = 1 +parse_squash_commits = true +ignore_merge_commits = true + [tool.pyright] pythonVersion = "3.10" typeCheckingMode = "basic" diff --git a/tests/test_release_tools.py b/tests/test_release_tools.py new file mode 100644 index 0000000..7c73eb3 --- /dev/null +++ b/tests/test_release_tools.py @@ -0,0 +1,90 @@ +import io +from pathlib import Path +import tarfile +import tempfile +import unittest +import zipfile + +from tools.release_policy import validate_title +from tools.verify_release_artifacts import verify_dist + + +class TestReleasePolicy(unittest.TestCase): + def test_accepts_release_types_scopes_and_breaking_marker(self): + valid_titles = ( + "fix: preserve closed descriptor cleanup", + "feat(io): add persistent wait sets", + "feat!: replace the scheduler contract", + "docs(release/guide): explain recovery", + "ci: pin the release action", + ) + + for title in valid_titles: + with self.subTest(title=title): + self.assertIsNone(validate_title(title)) + + def test_rejects_titles_semantic_release_cannot_use(self): + invalid_titles = ( + "Add persistent wait sets", + "unknown: change behavior", + "feat add persistent wait sets", + "feat: ", + "feat: first line\nsecond line", + ) + + for title in invalid_titles: + with self.subTest(title=title): + self.assertIsNotNone(validate_title(title)) + + +class TestReleaseArtifacts(unittest.TestCase): + @staticmethod + def _metadata(name="SmallPackage", version="1.2.3"): + return f"Metadata-Version: 2.2\nName: {name}\nVersion: {version}\n\n".encode() + + def _write_wheel(self, directory, metadata=None): + path = directory / "smallpackage-1.2.3-py3-none-any.whl" + with zipfile.ZipFile(path, mode="w") as archive: + archive.writestr( + "smallpackage-1.2.3.dist-info/METADATA", + metadata or self._metadata(), + ) + return path + + def _write_sdist(self, directory, metadata=None): + path = directory / "smallpackage-1.2.3.tar.gz" + payload = metadata or self._metadata() + member = tarfile.TarInfo("smallpackage-1.2.3/PKG-INFO") + member.size = len(payload) + with tarfile.open(path, mode="w:gz") as archive: + archive.addfile(member, io.BytesIO(payload)) + return path + + def test_verifies_one_matching_wheel_and_sdist(self): + with tempfile.TemporaryDirectory() as temporary_directory: + directory = Path(temporary_directory) + self._write_wheel(directory) + self._write_sdist(directory) + + verify_dist(directory, "SmallPackage", "1.2.3") + + def test_rejects_a_metadata_version_mismatch(self): + with tempfile.TemporaryDirectory() as temporary_directory: + directory = Path(temporary_directory) + self._write_wheel(directory, self._metadata(version="1.2.4")) + self._write_sdist(directory) + + with self.assertRaisesRegex(ValueError, "contains version"): + verify_dist(directory, "SmallPackage", "1.2.3") + + def test_rejects_missing_or_duplicate_distribution_types(self): + with tempfile.TemporaryDirectory() as temporary_directory: + directory = Path(temporary_directory) + self._write_wheel(directory) + + with self.assertRaisesRegex(ValueError, "one wheel and one sdist"): + verify_dist(directory, "SmallPackage", "1.2.3") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..1913a4c --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Repository-local maintenance tools; not part of the SmallPackage wheel.""" diff --git a/tools/release_policy.py b/tools/release_policy.py new file mode 100644 index 0000000..c9517a9 --- /dev/null +++ b/tools/release_policy.py @@ -0,0 +1,71 @@ +"""Validate release-bearing pull request titles. + +The release workflow uses Python Semantic Release's Conventional Commit parser. +Keeping this check dependency-free lets pull requests reject an incompatible +title before merge without exposing release credentials or installing release +tooling. +""" + +from __future__ import annotations + +import os +import re +import sys + + +ALLOWED_TYPES = ( + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "style", + "test", +) + +_TITLE_PATTERN = re.compile( + r"^(?P" + "|".join(ALLOWED_TYPES) + r")" + r"(?:\((?P[A-Za-z0-9][A-Za-z0-9._/-]*)\))?" + r"(?P!)?: (?P\S(?:.*\S)?)$" +) + + +def validate_title(title: str) -> str | None: + """Return an actionable error for an invalid title, otherwise ``None``.""" + + if "\n" in title or "\r" in title: + return "pull request titles must be a single line" + + match = _TITLE_PATTERN.fullmatch(title) + if match is None: + allowed = ", ".join(ALLOWED_TYPES) + return ( + "title must follow '(optional-scope)!: summary'; " + f"allowed types: {allowed}" + ) + + return None + + +def main() -> int: + """Validate ``SMALL_OS_PR_TITLE`` and return a process exit status.""" + + title = os.environ.get("SMALL_OS_PR_TITLE") + if title is None: + print("SMALL_OS_PR_TITLE is required", file=sys.stderr) + return 2 + + error = validate_title(title) + if error is not None: + print(f"Invalid pull request title: {error}", file=sys.stderr) + return 1 + + print(f"Release-compatible pull request title: {title}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_release_artifacts.py b/tools/verify_release_artifacts.py new file mode 100644 index 0000000..2a8458c --- /dev/null +++ b/tools/verify_release_artifacts.py @@ -0,0 +1,99 @@ +"""Verify that release distributions contain the expected name and version.""" + +from __future__ import annotations + +import argparse +import email.parser +import pathlib +import sys +import tarfile +import zipfile + + +def _metadata_fields(raw_metadata: bytes) -> tuple[str, str]: + metadata = email.parser.BytesParser().parsebytes(raw_metadata) + name = metadata.get("Name") + version = metadata.get("Version") + if name is None or version is None: + raise ValueError("distribution metadata must contain Name and Version") + return name, version + + +def wheel_metadata(path: pathlib.Path) -> tuple[str, str]: + """Return the core metadata name and version stored in a wheel.""" + + with zipfile.ZipFile(path) as archive: + members = [ + member + for member in archive.namelist() + if member.endswith(".dist-info/METADATA") + ] + if len(members) != 1: + raise ValueError(f"{path.name} must contain exactly one METADATA file") + return _metadata_fields(archive.read(members[0])) + + +def sdist_metadata(path: pathlib.Path) -> tuple[str, str]: + """Return the core metadata name and version stored in an sdist.""" + + with tarfile.open(path, mode="r:gz") as archive: + members = [ + member + for member in archive.getmembers() + if member.isfile() + and member.name.count("/") == 1 + and member.name.endswith("/PKG-INFO") + ] + if len(members) != 1: + raise ValueError(f"{path.name} must contain exactly one top-level PKG-INFO") + extracted = archive.extractfile(members[0]) + if extracted is None: + raise ValueError(f"could not read metadata from {path.name}") + return _metadata_fields(extracted.read()) + + +def verify_dist(directory: pathlib.Path, expected_name: str, expected_version: str) -> None: + """Verify one wheel and one sdist in ``directory`` against expectations.""" + + wheels = sorted(directory.glob("*.whl")) + sdists = sorted(directory.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise ValueError( + f"expected one wheel and one sdist, found {len(wheels)} wheel(s) " + f"and {len(sdists)} sdist(s)" + ) + + for path, reader in ((wheels[0], wheel_metadata), (sdists[0], sdist_metadata)): + name, version = reader(path) + if name.casefold() != expected_name.casefold(): + raise ValueError( + f"{path.name} contains project name {name!r}, expected {expected_name!r}" + ) + if version != expected_version: + raise ValueError( + f"{path.name} contains version {version!r}, expected {expected_version!r}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("directory", type=pathlib.Path) + parser.add_argument("--name", required=True) + parser.add_argument("--version", required=True) + arguments = parser.parse_args(argv) + + try: + verify_dist(arguments.directory, arguments.name, arguments.version) + except (OSError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error: + print(f"Release artifact verification failed: {error}", file=sys.stderr) + return 1 + + print( + f"Verified wheel and sdist for {arguments.name} {arguments.version} " + f"in {arguments.directory}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())