Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 20 additions & 11 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Creates the GitHub releases for `guppylang` and `guppylang-internals`.
#
# Release creation is driven by tags. Pushing a `guppylang-v*` or
# `guppylang-internals-v*` tag creates a draft GitHub release for the matching
# `guppylang-internals-v*` tag publishes a GitHub release for the matching
# package, with notes sliced verbatim from the committed CHANGELOG.md at that tag.
# Publishing that draft release triggers `python-wheels.yml`, which uploads the
# Publishing the release triggers `python-wheels.yml`, which uploads the
# wheels to PyPI. `guppylang` and `guppylang-internals` always share the exact
# same version, so a release tags both packages at that version.
#
Expand Down Expand Up @@ -78,7 +78,7 @@ jobs:
tag_pkg "guppylang-internals/pyproject.toml" "guppylang-internals-v"

# On a push of a package tag (from `tag-release-line`, or a manual `git push`),
# create the matching draft GitHub release.
# publish the matching GitHub release.
create-release:
name: Create the GitHub release for the pushed tag
if: github.ref_type == 'tag'
Expand All @@ -88,9 +88,16 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.HUGRBOT_PAT }}
- name: Classify the release
id: metadata
env:
TAG: ${{ github.ref_name }}
run: python3 scripts/release/gh_release_metadata.py "$TAG" --github-output "$GITHUB_OUTPUT"
- name: Create the GitHub release
env:
TAG: ${{ github.ref_name }}
LATEST: ${{ steps.metadata.outputs.latest }}
PRERELEASE: ${{ steps.metadata.outputs.prerelease }}
run: |
set -euo pipefail

Expand All @@ -111,8 +118,14 @@ jobs:
;;
esac

if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists; nothing to do."
flags=(--latest="$LATEST" --prerelease="$PRERELEASE")
if release=$(gh release view "$TAG" --json isDraft 2>/dev/null); then
if [ "$(jq -r '.isDraft' <<< "$release")" = true ]; then
# Publish an existing draft without replacing its notes.
gh release edit "$TAG" --draft=false --verify-tag "${flags[@]}"
else
echo "Release $TAG already exists; nothing to do."
fi
exit 0
fi

Expand All @@ -122,9 +135,5 @@ jobs:
: > /tmp/notes.md
fi

args=(--title "$name $version" --notes-file /tmp/notes.md --verify-tag --draft)
# PEP 440 pre-releases carry a '-' separator (e.g. 1.0.0-a6, 1.0.0-rc1).
case "$version" in
*-*) args+=(--prerelease) ;;
esac
gh release create "$TAG" "${args[@]}"
gh release create "$TAG" --title "$name $version" \
--notes-file /tmp/notes.md --verify-tag "${flags[@]}"
15 changes: 8 additions & 7 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,14 @@ present, and vice versa.
Release creation is **driven by tags**. When the release PR is merged (into `main`
or a release branch), the `tag-release-line` job in `release-publish.yml` tags both
packages (`guppylang-v<version>` and `guppylang-internals-v<version>`) and pushes
the tags. Each tag push then triggers the `create-release` job, which creates a
**draft** GitHub release for the matching package, with notes sliced verbatim from
the committed changelog at that tag.
the tags. Each tag push then triggers the `create-release` job, which publishes a
GitHub release for the matching package, with notes sliced verbatim from the
committed changelog at that tag. Publishing triggers `python-wheels.yml`, which
builds, checks, and uploads the wheels to PyPI.

Review each draft release and **publish** it when ready: publishing triggers
`python-wheels.yml`, which uploads the wheels to PyPI. Wheels are only published
once you publish the draft release, not when the tag first creates it.
Alpha, beta, and release candidates for either package are marked **Prerelease**
releases in GitHub. Only the highest stable `guppylang` tag is marked
**Latest**. Stable `guppylang-internals` releases get neither label.

### After-release chores

Expand All @@ -294,6 +295,6 @@ Once the backport is complete (the backport PR is merged), the `B-backport-nomin

### Patch releases

Once the backport is complete, `release-pr.yml` then opens/updates the release PR on `release-pr--release/<major>.<minor>` with the next patch version. Merge it, and `release-publish.yml` tags and drafts the releases as usual.
Once the backport is complete, `release-pr.yml` then opens/updates the release PR on `release-pr--release/<major>.<minor>` with the next patch version. Merge it, and `release-publish.yml` tags and publishes the releases as usual.
After the release is published, make sure the version and changelog changes are
reflected on `main` so the history stays consistent.
86 changes: 86 additions & 0 deletions scripts/release/gh_release_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Choose the GitHub release flags for a package tag.

Takes a ``guppylang-v<version>`` or ``guppylang-internals-v<version>`` tag as a
positional argument and reads the local Git tags in the current repository.
Fetch all release tags before running the script so the comparison is complete.

Writes ``package``, ``version``, ``prerelease``, and ``latest`` to stdout as one
``key=value`` pair per line. The flags are lowercase ``true`` or ``false``.
If ``--github-output <path>`` is given, appends the same lines to that file.

Only the highest stable ``guppylang`` tag can be marked latest. Comparing all
tags also accounts for releases that have not yet been published.
"""

# ruff: noqa: INP001 -- Release scripts are standalone entry points.

from __future__ import annotations

import argparse
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING

from compute_versions import parse_guppy_version

if TYPE_CHECKING:
from collections.abc import Iterable


def release_metadata(tag: str, tags: Iterable[str]) -> dict[str, str]:
"""Return the package, version, and release flags for ``tag``.

Stable internals releases and older stable Guppy releases get neither the
latest nor the prerelease flag. Alpha, beta, and release candidates for
either package get the prerelease flag.
"""
for package in ("guppylang", "guppylang-internals"):
prefix = f"{package}-v"
if tag.startswith(prefix):
version = tag.removeprefix(prefix)
parsed = parse_guppy_version(version)
break
else:
raise ValueError(f"Not a Guppy package tag: {tag!r}")

latest = package == "guppylang" and not parsed.is_prerelease
if latest:
for other_tag in tags:
if not other_tag.startswith("guppylang-v"):
continue
try:
other = parse_guppy_version(other_tag.removeprefix("guppylang-v"))
except ValueError:
continue
if not other.is_prerelease and other > parsed:
latest = False
break

return {
"package": package,
"version": version,
"prerelease": str(parsed.is_prerelease).lower(),
"latest": str(latest).lower(),
}


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tag", help="Package tag to publish.")
parser.add_argument("--github-output", help="Append metadata to GITHUB_OUTPUT.")
args = parser.parse_args(argv)
tags = subprocess.check_output(
["git", "tag", "--list", "guppylang-v*"], # noqa: S607
text=True,
).splitlines()
metadata = release_metadata(args.tag, tags)
output = "".join(f"{key}={value}\n" for key, value in metadata.items())
print(output, end="")
if args.github_output:
with Path(args.github_output).open("a", encoding="utf-8") as file:
file.write(output)
return 0


if __name__ == "__main__":
raise SystemExit(main())
103 changes: 103 additions & 0 deletions tests/release/test_gh_release_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for classifying package tags before publishing GitHub releases."""

# ruff: noqa: S603, S607 -- Tests run git in a temporary repository.

from __future__ import annotations

import subprocess
from typing import TYPE_CHECKING

import gh_release_metadata as rm
import pytest

if TYPE_CHECKING:
from pathlib import Path


@pytest.mark.parametrize(
("version", "other_versions", "latest"),
[
("1.0.0", [], True),
("1.3.0", ["1.2.9", "1.3.0-rc1", "1.4.0-a0"], True),
("1.3.1", ["1.3.0", "1.4.0-rc0"], True),
("1.2.10", ["1.3.0"], False),
("1.3.1", ["1.3.2"], False),
("1.9.9", ["1.10.0"], False),
("1.10.0", ["1.9.9", "1.10.0"], True),
("1.2.10", ["1.2.9"], True),
("1.2.9", ["1.2.10"], False),
("1.99.99", ["2.0.0"], False),
Comment on lines +20 to +25

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could reduce the number of cases here, some of them are definitely redundant, like

("1.2.10", ["1.3.0"], False)
("1.9.9", ["1.10.0"], False)

],
)
def test_stable_guppy_latest(
version: str, other_versions: list[str], latest: bool
) -> None:
tags = [f"guppylang-v{other}" for other in other_versions]
metadata = rm.release_metadata(f"guppylang-v{version}", tags)
assert metadata == {
"package": "guppylang",
"version": version,
"prerelease": "false",
"latest": str(latest).lower(),
}


@pytest.mark.parametrize("package", ["guppylang", "guppylang-internals"])
@pytest.mark.parametrize("version", ["2.0.0-a0", "2.0.0-b1", "2.0.0-rc2", "2.0.0rc3"])
def test_prereleases(package: str, version: str) -> None:
metadata = rm.release_metadata(f"{package}-v{version}", ["guppylang-v1.0.0"])
assert metadata["package"] == package
assert metadata["version"] == version
assert metadata["prerelease"] == "true"
assert metadata["latest"] == "false"


def test_stable_internals_gets_neither_label() -> None:
metadata = rm.release_metadata("guppylang-internals-v2.0.0", [])
assert metadata["prerelease"] == "false"
assert metadata["latest"] == "false"


def test_other_packages_and_malformed_tags_do_not_affect_latest() -> None:
metadata = rm.release_metadata(
"guppylang-v1.0.0",
["guppylang-internals-v9.0.0", "other-v9.0.0", "guppylang-vinvalid"],
)
assert metadata["latest"] == "true"


@pytest.mark.parametrize("tag", ["other-v1.0.0", "guppylang-vinvalid"])
def test_invalid_release_tag(tag: str) -> None:
with pytest.raises(ValueError, match=r"Not a Guppy package tag|Cannot parse"):
rm.release_metadata(tag, [])


def test_cli_compares_git_tags_and_appends_outputs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
subprocess.run(["git", "init", "-q"], check=True)
subprocess.run(
[
"git",
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qm",
"Initial commit",
"--allow-empty",
],
check=True,
)
for tag in ("guppylang-v1.2.10", "guppylang-v1.3.0", "guppylang-v1.4.0-a0"):
subprocess.run(["git", "tag", tag], check=True)

output = tmp_path / "output"
output.write_text("existing=value\n")
assert rm.main(["guppylang-v1.2.10", "--github-output", str(output)]) == 0
assert output.read_text() == (
"existing=value\npackage=guppylang\nversion=1.2.10\n"
"prerelease=false\nlatest=false\n"
)
Loading