diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 745bddd..5420c29 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -15,6 +15,8 @@ on: - "scripts/generate_release_metadata.py" - "scripts/validate_release_metadata.py" - "scripts/validate_changelog.py" + - "scripts/validate_release_ref.py" + - "CHANGELOG.md" - "examples/**" - "compatibility/**" - "docs/releasing.md" @@ -84,6 +86,12 @@ jobs: - name: Validate changelog run: python scripts/validate_changelog.py + - name: Validate tagged release notes + if: ${{ github.ref_type == 'tag' }} + env: + RELEASE_TAG: ${{ github.ref_name }} + run: python scripts/validate_release_ref.py + - name: Prepare clean artifact destination run: | git clean -ffdx @@ -247,3 +255,40 @@ jobs: with: subject-checksums: dist/SHA256SUMS sbom-path: dist/SBOM.spdx.json + + release: + name: Create GitHub Release + needs: [build, smoke, publish, attest] + if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download reviewed distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: base-cli-dist-${{ github.run_id }} + path: dist + + - name: Download release metadata + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: base-cli-release-metadata-${{ github.run_id }} + path: dist + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="$GITHUB_REF_NAME" + assets=(dist/*.whl dist/*.tar.gz dist/SHA256SUMS dist/SBOM.spdx.json) + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$tag" "${assets[@]}" --clobber --repo "$GITHUB_REPOSITORY" + else + gh release create "$tag" "${assets[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$tag" \ + --generate-notes \ + --notes "Published distributions and release metadata for $tag. See CHANGELOG.md for the reviewed release notes." + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index bec2d7c..d7fe6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ and versions are tracked in the repo-root `VERSION` file. ### Added +- Automate GitHub Releases from matching version tags with reviewed + distributions, checksums, SBOM metadata, and generated comparison notes. - Add framework-specific migration guides for Click, Typer, Cement, and `argparse`, with rollout and rollback checklists. - Add golden success, error, inspection, log, NDJSON, and command-protocol diff --git a/docs/releasing.md b/docs/releasing.md index 1814271..0b2a999 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -35,6 +35,14 @@ runs, GitHub's OIDC-backed `actions/attest` job records both build provenance and an SBOM attestation for the exact artifact digests; no PyPI token or other long-lived publish secret is used. +For a version tag, the same Package workflow creates a GitHub Release after +the protected PyPI publication and attestations succeed. The release attaches +the exact reviewed wheel, sdist, `SHA256SUMS`, and `SBOM.spdx.json` downloaded +from the build job. GitHub-generated comparison notes are supplemented by the +dated section in `CHANGELOG.md`; the tagged release is rejected when `VERSION` +or that section does not match the tag. Rerunning a tag updates an existing +release's assets with `--clobber` instead of creating a second release. + ## Independent verification Download the release metadata artifact from the successful Package workflow @@ -114,9 +122,11 @@ publishing for this repository and workflow before the dispatch can upload. 1. Update `VERSION` and the changelog in a reviewed pull request. 2. Merge to `main` and create the matching `v${VERSION}` tag. 3. Approve the protected `pypi` environment. The workflow verifies the tag, - builds and tests the artifact, then publishes the exact artifact to PyPI via - trusted publishing. -4. Verify installation from PyPI: + dated changelog section, builds and tests the artifact, then publishes the + exact artifact to PyPI via trusted publishing and creates the matching + GitHub Release. +4. Verify installation from PyPI and download the matching GitHub Release + assets: ```bash python -m venv /tmp/base-cli-smoke diff --git a/scripts/validate_release_ref.py b/scripts/validate_release_ref.py new file mode 100644 index 0000000..42f4a72 --- /dev/null +++ b/scripts/validate_release_ref.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Validate that a version tag has matching package and release notes.""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from pathlib import Path + +RELEASE_HEADING = re.compile(r"^## \[(?P\d+\.\d+\.\d+)\] - (?P\d{4}-\d{2}-\d{2})$") +BULLET = re.compile(r"^\s*[-*+]\s+\S") + + +def validate_release_ref(version_path: Path, changelog_path: Path, tag: str) -> list[str]: + """Return violations for a release ``tag`` and its source files.""" + errors: list[str] = [] + if not tag.startswith("v") or tag == "v": + return [f"release tag must be a v-prefixed version, got {tag!r}"] + version = tag[1:] + declared = version_path.read_text(encoding="utf-8").strip() + if declared != version: + errors.append(f"VERSION declares {declared!r}, but the release tag is {tag!r}") + + lines = changelog_path.read_text(encoding="utf-8").splitlines() + heading = f"## [{version}] - " + heading_index = next( + (index for index, line in enumerate(lines) if line.startswith(heading)), + None, + ) + if heading_index is None or RELEASE_HEADING.fullmatch(lines[heading_index]) is None: + errors.append(f"CHANGELOG.md is missing a dated release section for [{version}]") + return errors + + next_section = next( + (index for index in range(heading_index + 1, len(lines)) if lines[index].startswith("## ")), + len(lines), + ) + if not any(BULLET.match(line) for line in lines[heading_index + 1 : next_section]): + errors.append(f"CHANGELOG.md release section [{version}] has no release-note bullets") + return errors + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", default=os.environ.get("RELEASE_TAG", "")) + parser.add_argument("--version-file", type=Path, default=Path("VERSION")) + parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md")) + args = parser.parse_args() + errors = validate_release_ref(args.version_file, args.changelog, args.tag) + if errors: + for error in errors: + print(f"release reference validation failed: {error}", file=sys.stderr) + raise SystemExit(1) + print(f"Validated release reference {args.tag}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_validate_release_ref.py b/tests/test_validate_release_ref.py new file mode 100644 index 0000000..af0056c --- /dev/null +++ b/tests/test_validate_release_ref.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from scripts import validate_release_ref + +VALID_CHANGELOG = """\ +# Changelog + +## [Unreleased] + +### Added + +- Continue improvements. + +## [1.2.3] - 2026-08-28 + +### Fixed + +- Repair the release workflow. +""" + + +class ReleaseReferenceValidationTests(unittest.TestCase): + def validate(self, version: str, changelog: str, tag: str) -> list[str]: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + version_path = root / "VERSION" + changelog_path = root / "CHANGELOG.md" + version_path.write_text(version, encoding="utf-8") + changelog_path.write_text(changelog, encoding="utf-8") + return validate_release_ref.validate_release_ref(version_path, changelog_path, tag) + + def test_accepts_matching_version_and_release_notes(self) -> None: + self.assertEqual(self.validate("1.2.3\n", VALID_CHANGELOG, "v1.2.3"), []) + + def test_rejects_mismatched_version(self) -> None: + errors = self.validate("1.2.2\n", VALID_CHANGELOG, "v1.2.3") + self.assertTrue(any("VERSION declares" in error for error in errors)) + + def test_rejects_missing_release_notes(self) -> None: + changelog = VALID_CHANGELOG.replace("- Repair the release workflow.\n", "") + errors = self.validate("1.2.3\n", changelog, "v1.2.3") + self.assertTrue(any("no release-note bullets" in error for error in errors)) + + def test_rejects_undated_or_missing_section(self) -> None: + changelog = VALID_CHANGELOG.replace("## [1.2.3] - 2026-08-28", "## [1.2.3]") + errors = self.validate("1.2.3\n", changelog, "v1.2.3") + self.assertTrue(any("missing a dated release section" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main()