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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions scripts/validate_release_ref.py
Original file line number Diff line number Diff line change
@@ -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<version>\d+\.\d+\.\d+)\] - (?P<date>\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()
57 changes: 57 additions & 0 deletions tests/test_validate_release_ref.py
Original file line number Diff line number Diff line change
@@ -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()
Loading