diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..de26ece --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,154 @@ +name: Prepare Release + +# Cut a release entirely from GitHub: Actions -> Prepare Release -> Run workflow. +# +# This bumps the version in pyproject.toml and the package __init__.py, turns +# the "## Unreleased" CHANGELOG.md section into the new version's entry, commits +# to the default branch, then creates and pushes an annotated v#.#.# tag. The +# tag push starts the Release workflow (build, PyPI upload, GitHub Release, +# docs) as its own run. +# +# The default branch is protected, so the commit and tag are pushed with the +# RELEASE_TOKEN repository secret: a token from an account allowed to bypass +# the branch protection rule. A token other than GITHUB_TOKEN is also what lets +# the tag push trigger release.yml. + +on: + workflow_dispatch: + inputs: + bump: + description: Version component to bump (ignored if an explicit version is given) + required: true + default: patch + type: choice + options: + - patch + - minor + - major + version: + description: Explicit version to release, e.g. 1.2.3 (optional, overrides bump) + required: false + type: string + require_changelog: + description: Fail if CHANGELOG.md has neither an Unreleased section with entries nor an entry for the new version + required: false + default: false + type: boolean + +# Pushes use RELEASE_TOKEN, so GITHUB_TOKEN only needs to read. +permissions: + contents: read + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + + steps: + - name: Require default branch + if: github.ref_name != github.event.repository.default_branch + run: | + echo "::error::Releases must be cut from ${{ github.event.repository.default_branch }} (got ${{ github.ref_name }})" + exit 1 + + - name: Require RELEASE_TOKEN + env: + HAS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN != '' }} + run: | + if [ "${HAS_RELEASE_TOKEN}" != "true" ]; then + echo "::error::The RELEASE_TOKEN secret is not set; it is needed to push past branch protection" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install pipenv + run: pip install pipenv + + - name: Set up project environment + run: make setup + + - name: Install project package + run: make install-self + + - name: Run tests + run: make tests + + - name: Determine new version + id: version + env: + BUMP: ${{ inputs.bump }} + EXPLICIT: ${{ inputs.version }} + run: | + new_version="$(python3 - <<'PY' + import os, re, sys, tomllib + + current = tomllib.load(open("pyproject.toml", "rb"))["project"]["version"] + explicit = os.environ.get("EXPLICIT", "").strip().lstrip("v") + semver = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + if explicit: + if not semver.match(explicit): + sys.exit(f"Invalid version {explicit!r}; expected #.#.#") + print(explicit) + sys.exit(0) + + m = semver.match(current) + if not m: + sys.exit(f"Current version {current!r} is not #.#.#; pass an explicit version") + major, minor, patch = map(int, m.groups()) + bump = os.environ["BUMP"] + if bump == "major": + major, minor, patch = major + 1, 0, 0 + elif bump == "minor": + minor, patch = minor + 1, 0 + else: + patch += 1 + print(f"{major}.{minor}.{patch}") + PY + )" + echo "New version: ${new_version}" + if git rev-parse -q --verify "refs/tags/v${new_version}" >/dev/null; then + echo "::error::Tag v${new_version} already exists" + exit 1 + fi + echo "version=${new_version}" >> "$GITHUB_OUTPUT" + + - name: Update CHANGELOG + env: + REQUIRE: ${{ inputs.require_changelog }} + VERSION: ${{ steps.version.outputs.version }} + run: | + if ! python3 scripts/changelog.py release "${VERSION}"; then + if [ "${REQUIRE}" = "true" ]; then + echo "::error::CHANGELOG.md has no entries under '## Unreleased' and no '## v${VERSION}' entry" + exit 1 + fi + echo "::warning::CHANGELOG.md has no entries for v${VERSION}; the GitHub Release will only have auto-generated notes" + fi + + - name: Bump version + run: make change-version VERSION=${{ steps.version.outputs.version }} + + - name: Commit, tag, and push + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml src/pyhatching/__init__.py CHANGELOG.md + git commit -m "build: release v${VERSION}" + git tag -a "v${VERSION}" -m "Release v${VERSION}" + git push --atomic origin "HEAD:${GITHUB_REF_NAME}" "refs/tags/v${VERSION}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78ea8b3..38fa096 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,7 @@ name: Release +# Runs on every v* tag push: from the "Prepare Release" workflow (pushed with +# RELEASE_TOKEN) or from `make release` run locally. on: push: tags: @@ -8,13 +10,22 @@ on: permissions: contents: write +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ github.ref_name }} + steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: refs/tags/${{ env.RELEASE_TAG }} - name: Set up Python uses: actions/setup-python@v5 @@ -27,6 +38,14 @@ jobs: - name: Set up project environment run: make setup + - name: Verify tag matches package version + run: | + pkg_version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + if [ "v${pkg_version}" != "${RELEASE_TAG}" ]; then + echo "::error::Tag ${RELEASE_TAG} does not match pyproject.toml version ${pkg_version}" + exit 1 + fi + - name: Build package run: make build @@ -36,9 +55,24 @@ jobs: - name: Package documentation run: tar -czf docs.tar.gz -C docs . + - name: Extract release notes from CHANGELOG + run: python3 scripts/changelog.py notes "${RELEASE_TAG}" > release-notes.md || true + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_DEPLOY_TOKEN }} + run: | + pipenv run python3 -m twine check dist/* + pipenv run python3 -m twine upload --non-interactive --skip-existing dist/* + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ env.RELEASE_TAG }} + name: ${{ env.RELEASE_TAG }} + body_path: release-notes.md + generate_release_notes: true files: | dist/* docs.tar.gz @@ -49,6 +83,6 @@ jobs: run: | gh api repos/gormaniac/docs.gormo.co/dispatches \ -f event_type=doc-release \ - -f 'client_payload[tag]=${{ github.ref_name }}' \ + -f "client_payload[tag]=${RELEASE_TAG}" \ -f 'client_payload[repository]=${{ github.repository }}' \ - -f 'client_payload[github_sha]=${{ github.sha }}' + -f "client_payload[github_sha]=$(git rev-parse HEAD)" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2225234 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +## Unreleased +### Features +- Support the full Hatching Triage API (public, private, and self-hosted instances). +- Add `pyhatching.testing.FakeTriage`, a fake Triage API server for testing code built on pyhatching. +- Add a live test suite that runs against tria.ge when `HATCHING_TOKEN` is set. +- Publish the Python package to PyPI from the Release GitHub Action. +- Add a manually run Prepare Release GitHub Action that bumps the version, rolls the CHANGELOG, tags, and runs the release. +- Add `make tests` to run the test suite. +### Fixes +- Resolve hashes in the streaming downloads and `kernel_report`. diff --git a/Makefile b/Makefile index 7d7d24a..eea82c4 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,8 @@ read-docs: # Open the package docs locally .PHONY: release release: change-version clean setup build docs # Build a new versioned release and push it (requires VERSION=#.#.#) - git add doc/* pyproject.toml $(PKG_DIR)/__init__.py + python3 scripts/changelog.py release $(VERSION) || echo "WARNING: no CHANGELOG entries for v$(VERSION)" + git add doc/* pyproject.toml $(PKG_DIR)/__init__.py CHANGELOG.md git commit -m "build: release v$(VERSION)" git push git tag -a v$(VERSION) -m "Release v$(VERSION)" @@ -60,3 +61,7 @@ release: change-version clean setup build docs # Build a new versioned release a .PHONY: pypi pypi: # Upload all Python packages in "dist/" to PyPI. pipenv run python3 -m twine upload dist/* + +.PHONY: tests +tests: # Run the pytest suite for this project. + pipenv run pytest tests/ diff --git a/README.md b/README.md index 5df724d..2754391 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,20 @@ Supports the whole Triage API (public, private, and self-hosted instances): `pyhatching.testing.FakeTriage` is a fake Triage API server for testing code built on pyhatching. Read the full documentation [here](https://gormo.co/docs/pyhatching/). + +## Releasing + +Releases are cut from GitHub Actions with the **Prepare Release** workflow, and pending changes are tracked under a `## Unreleased` header in `CHANGELOG.md`. + +1. Add entries to the `## Unreleased` section at the top of `CHANGELOG.md` (under its `### Features` and `### Fixes` headers) as changes land on `main`. +2. Go to **Actions → Prepare Release → Run workflow** on `main`, pick a `patch`/`minor`/`major` bump (or enter an explicit version). + +The workflow runs the tests, bumps the version, renames `## Unreleased` to `## v#.#.# - ` (starting a fresh empty `## Unreleased` above it), commits, and pushes a `v#.#.#` tag. The **Release** workflow then builds the package, uploads it to PyPI (using the `PYPI_DEPLOY_TOKEN` secret), creates a GitHub Release with the changelog entry as notes and the package and docs attached, and notifies the docs site. Pushing a `v*` tag manually (e.g. `make release VERSION=#.#.#`) also triggers the Release workflow. + +`main` is protected, so Prepare Release pushes the release commit and tag with the `RELEASE_TOKEN` repository secret: a token with **Contents: read and write** from an account allowed to bypass the branch protection rule. The tag push starts the Release workflow as its own run under Actions. + +The workflows rely on these repository secrets: + +- `PYPI_DEPLOY_TOKEN` - the PyPI API token used to upload the package. +- `DOCS_DISPATCH_TOKEN` - used to notify the docs site repo. +- `RELEASE_TOKEN` (required by Prepare Release) - see above. If it expires or is revoked, the push step fails and the token needs to be replaced. diff --git a/scripts/changelog.py b/scripts/changelog.py new file mode 100644 index 0000000..6a2558b --- /dev/null +++ b/scripts/changelog.py @@ -0,0 +1,116 @@ +"""Manage CHANGELOG.md entries. + +Intended to be executed from project root. + +Usage: + python3 scripts/changelog.py notes + Print the body of the CHANGELOG entry for . Exits 1 if there + is no entry for it. + + python3 scripts/changelog.py release [] + Turn the "## Unreleased" section into "## v - " (date + defaults to today, UTC) and start a fresh, empty "## Unreleased" + section above it. Does nothing if already has an entry. + Exits 1 if there is neither an entry for nor a non-empty + Unreleased section. + + may be given with or without a leading "v". +""" + +import datetime +import re +import sys + +CHANGELOG = "CHANGELOG.md" +UNRELEASED = "## Unreleased" +SUBSECTIONS = ("### Features", "### Fixes") +UNRELEASED_TEMPLATE = UNRELEASED + "\n" + "\n".join(SUBSECTIONS) + "\n" + + +def read(): + with open(CHANGELOG, "r") as fd: + return fd.read() + + +def split_sections(text): + """Split the changelog into (preamble, [section, ...]) on "## " headers.""" + parts = re.split(r"(?m)^(?=## )", text) + return parts[0], parts[1:] + + +def is_version_header(section, version): + first = section.splitlines()[0] + return re.match(rf"^## v{re.escape(version)}(\s|$)", first) is not None + + +def has_content(section): + """True if a section has anything beyond its headers and blank lines.""" + return any( + line.strip() and not line.startswith("#") for line in section.splitlines() + ) + + +def fill_empty_subsections(body): + """Put "- N/A" under any Features/Fixes header that has no entries.""" + lines = body.rstrip("\n").splitlines() + out = [] + for i, line in enumerate(lines): + out.append(line) + if line.strip() in SUBSECTIONS: + nxt = next((l for l in lines[i + 1 :] if l.strip()), None) + if nxt is None or nxt.startswith("#"): + out.append("- N/A") + return "\n".join(out) + "\n\n" + + +def notes(version): + _, sections = split_sections(read()) + for section in sections: + if is_version_header(section, version): + print(section.split("\n", 1)[1].strip() if "\n" in section else "") + return 0 + print(f"No CHANGELOG entry found for v{version}", file=sys.stderr) + return 1 + + +def release(version, date): + preamble, sections = split_sections(read()) + + if any(is_version_header(s, version) for s in sections): + print(f"CHANGELOG already has an entry for v{version}", file=sys.stderr) + return 0 + + for i, section in enumerate(sections): + if section.splitlines()[0].strip() == UNRELEASED: + if not has_content(section): + break + body = section.split("\n", 1)[1] if "\n" in section else "" + released = f"## v{version} - {date}\n" + fill_empty_subsections(body) + sections[i] = UNRELEASED_TEMPLATE + "\n" + released + with open(CHANGELOG, "w") as fd: + fd.write(preamble + "".join(sections)) + print(f"Released the Unreleased CHANGELOG section as v{version}", file=sys.stderr) + return 0 + + print( + f"No CHANGELOG entry for v{version} and no non-empty '{UNRELEASED}' section", + file=sys.stderr, + ) + return 1 + + +def main(argv): + if len(argv) < 2 or argv[0] not in ("notes", "release"): + print(__doc__, file=sys.stderr) + return 2 + + version = argv[1].lstrip("v") + if argv[0] == "notes": + return notes(version) + + date = argv[2] if len(argv) > 2 else datetime.datetime.now(datetime.UTC).date().isoformat() + return release(version, date) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))