From c9c236d8f9afa417460c523e7428ad76e8f1e1a8 Mon Sep 17 00:00:00 2001 From: masklinn Date: Wed, 24 Dec 2025 19:56:01 +0100 Subject: [PATCH] Calver release of builtins The updated workflow relies on the two new scripts: - `relrev` retrieves the REVISION of the latest published package - `tagcore` updates uap-core to the specified revision and writes out the commit hash to REVISION, printing it out to stdout The workflow calls those two scripts and check if they differ, in which case it cuts a new release. If the two revisions match the release is skipped. Fixes #277 --- .github/workflows/release-builtins.yml | 48 ++++++++++++----- scripts/relrev.py | 67 ++++++++++++++++++++++++ scripts/tagcore.py | 72 ++++++++++++++++++++++++++ ua-parser-builtins/hatch_build.py | 4 ++ 4 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 scripts/relrev.py create mode 100644 scripts/tagcore.py diff --git a/.github/workflows/release-builtins.yml b/.github/workflows/release-builtins.yml index 408f9c7..538ef90 100644 --- a/.github/workflows/release-builtins.yml +++ b/.github/workflows/release-builtins.yml @@ -20,30 +20,56 @@ jobs: build: name: Build distribution runs-on: ubuntu-latest - + outputs: + release: ${{ steps.check.outputs.release }} steps: - uses: actions/checkout@v4 with: submodules: true fetch-depth: 0 persist-credentials: false - - name: update core - env: - TAG: ${{ inputs.tag || 'origin/master' }} - # needs to detach because we can update to a tag - run: git -C uap-core switch --detach "$TAG" - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" + - name: Check necessity of release + id: check + env: + PYPI: ${{ github.event.inputs.environment }} + REF: ${{ inputs.tag || 'HEAD' }} + run: | + case $PYPI in + pypi) + DOMAIN=pypi.org + ;; + testpypi) + DOMAIN=test.pypi.org + ;; + *) + exit 1 + esac + + RELREV=$(python scripts/relrev.py --domain "$DOMAIN") + VERSION=$(date +%Y%m) + CURREV=$(python scripts/tagcore.py --ref $REF --version $VERSION) + + if [ -n "$CURREV" -a "$RELREV" = "$CURREV" ] + then + echo "current rev matches latest release, skip new release" + else + echo release=true >> $GITHUB_OUTPUT + fi - name: Install pypa/build + if: ${{ steps.check.outputs.release == 'true' }} run: python3 -m pip install build --user - name: Build wheel + if: ${{ steps.check.outputs.release == 'true' }} run: | python3 -m build -w ua-parser-builtins mv ua-parser-builtins/dist . - name: Store the distribution packages + if: ${{ steps.check.outputs.release == 'true' }} uses: actions/upload-artifact@v4 with: name: python-package-distributions @@ -51,9 +77,8 @@ jobs: publish-to-testpypi: name: Publish to TestPyPI - if: ${{ github.event.inputs.environment == 'testpypi' }} - needs: - - build + needs: build + if: ${{ github.event.inputs.environment == 'testpypi' && needs.build.outputs.release == 'true' }} runs-on: ubuntu-latest environment: @@ -78,9 +103,8 @@ jobs: publish-to-pypi: name: publish - if: ${{ github.event_name == 'schedule' || github.event.inputs.environment == 'pypi' }} - needs: - - build + needs: build + if: ${{ (github.event_name == 'schedule' || github.event.inputs.environment == 'pypi') && needs.build.outputs.release == 'true' }} runs-on: ubuntu-latest environment: name: pypi diff --git a/scripts/relrev.py b/scripts/relrev.py new file mode 100644 index 0000000..0979a40 --- /dev/null +++ b/scripts/relrev.py @@ -0,0 +1,67 @@ +import argparse +import contextlib +import hashlib +import json +import re +import shutil +import sys +import tempfile +import zipfile +from urllib import parse, request + +parser = argparse.ArgumentParser( + description="Retrieves the revision for the latest release of ua-parser-builtins", +) +parser.add_argument( + "--domain", + default="pypi.org", +) +args = parser.parse_args() + +url = parse.urlunsplit(("https", args.domain, "simple/ua-parser-builtins", "", "")) + +print("checking", url, file=sys.stderr) +res = request.urlopen( + request.Request( + url, + headers={ + "Accept": "application/vnd.pypi.simple.v1+json", + }, + ) +) +if res.status != 200: + exit(f"Failed to retrieve project distributions: {res.status}") + +distributions = json.load(res) +version, distribution = next( + (v, d) + for v, d in zip( + reversed(distributions["versions"]), reversed(distributions["files"]) + ) + if not d["yanked"] + if re.fullmatch( + r"(\d+!)?\d+(\.\d+)*(\.post\d+)?", + v, + flags=re.ASCII, + ) +) +print("latest version:", version, file=sys.stderr) + +res = request.urlopen(distribution["url"]) +if res.status != 200: + exit(f"Failed to retrieve wheel: {res.status}") + +with tempfile.SpooledTemporaryFile(256 * 1024) as tf: + shutil.copyfileobj(res, tf) + for name, val in distribution["hashes"].items(): + tf.seek(0) + d = hashlib.file_digest(tf, name).hexdigest() + if d != val: + exit(f"{name} mismatch: expected {val!r} got {d!r}") + tf.seek(0) + with zipfile.ZipFile(tf) as z: + # if the REVISION file is not found then it's fine it's a + # pre-calver release (hopefully) and that means we should cut + # a calver one + with contextlib.suppress(KeyError): + print(z.read("REVISION").decode()) diff --git a/scripts/tagcore.py b/scripts/tagcore.py new file mode 100644 index 0000000..a5ef7f8 --- /dev/null +++ b/scripts/tagcore.py @@ -0,0 +1,72 @@ +import argparse +import datetime +import pathlib +import shutil +import subprocess + +CORE_REMOTE = "https://github.com/ua-parser/uap-core" + + +parser = argparse.ArgumentParser( + description="""Updates `uap-core` to `ref` and tags it with `version` + +If successful, writes the commit to `REVISION` and prints it to stdout. +""" +) +parser.add_argument( + "--ref", + default="HEAD", + help="uap-core ref to build, defaults to HEAD (the head of the default branch)", +) +parser.add_argument( + "--version", + help="version to tag the package as, defaults to an YMD calendar version matching the ref's commit date", +) +args = parser.parse_args() + + +if not shutil.which("git"): + exit("git required") + +r = subprocess.run( + ["git", "ls-remote", CORE_REMOTE, args.ref], + encoding="utf-8", + stdout=subprocess.PIPE, +) +if r.returncode: + exit("Unable to query uap-core repo") + +if r.stdout: + if r.stdout.count("\n") > 1: + exit(f"Found multiple matching refs for {args.ref}:\n{r.stdout}") + commit, _rest = r.stdout.split("\t", 1) +else: + try: + int(args.ref, 16) + commit = args.ref + except ValueError: + exit(f"Unknown or invalid ref {args.ref!r}") + +CORE_PATH = pathlib.Path(__file__).resolve().parent.parent / "uap-core" + +r = subprocess.run(["git", "-C", CORE_PATH, "fetch", CORE_REMOTE, commit]) +if r.returncode: + exit(f"Unable to retrieve commit {commit!r}") + +if args.version: + tagname = args.version +else: + r = subprocess.run( + ["git", "-C", CORE_PATH, "show", "-s", "--format=%cs", commit], + encoding="utf-8", + stdout=subprocess.PIPE, + ) + if r.returncode or not r.stdout: + exit(f"Unable to retrieve commit date from commit {commit!r}") + + tagname = datetime.date.fromisoformat(r.stdout.rstrip()).strftime("%Y%m%d") + +subprocess.run(["git", "-C", CORE_PATH, "switch", "-d", commit]) +subprocess.run(["git", "-C", CORE_PATH, "tag", tagname, commit]) +CORE_PATH.joinpath("REVISION").write_text(commit) +print(commit) diff --git a/ua-parser-builtins/hatch_build.py b/ua-parser-builtins/hatch_build.py index 9bbe23f..2a86012 100644 --- a/ua-parser-builtins/hatch_build.py +++ b/ua-parser-builtins/hatch_build.py @@ -41,6 +41,10 @@ def initialize( version: str, build_data: dict[str, Any], ) -> None: + rev = os.path.join(self.root, "uap-core/REVISION") + if os.path.exists(rev): + build_data["force_include"][rev] = "REVISION" + with open(os.path.join(self.root, "uap-core/regexes.yaml"), "rb") as f: data = yaml.safe_load(f)