diff --git a/.github/workflows/check-ooniauth-py-version-bump.yml b/.github/workflows/check-ooniauth-py-version-bump.yml deleted file mode 100644 index d54a349..0000000 --- a/.github/workflows/check-ooniauth-py-version-bump.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Check ooniauth-py version bump -# This workflow checks that we're bumping the library version before publishing to PyPi. -# If you don't bump it, the upload will fail after merge since you can only upload new versions -on: - pull_request: - branches: - - main - paths: - - "ooniauth-py/**" - - "ooniauth-core/**" - -jobs: - check-version-bump: - runs-on: ubuntu-latest - steps: - - name: Checkout PR branch - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Check version bumped vs main - shell: bash - run: | - set -euo pipefail - - FILE="ooniauth-py/Cargo.toml" - - if [ ! -f "$FILE" ]; then - echo "Missing $FILE in PR branch" - exit 1 - fi - - git fetch origin main - - MAIN_VERSION="$(git show origin/main:$FILE | sed -n 's/^version = "\(.*\)"/\1/p' | head -n1 || true)" - PR_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$FILE" | head -n1 || true)" - - if [ -z "$MAIN_VERSION" ] || [ -z "$PR_VERSION" ]; then - echo "Could not parse version from $FILE" - echo "main='$MAIN_VERSION' pr='$PR_VERSION'" - exit 1 - fi - - echo "main version: $MAIN_VERSION" - echo "PR version: $PR_VERSION" - - # Check that the new version is higher than the version in Main - HIGHEST="$(printf '%s\n%s\n' "$MAIN_VERSION" "$PR_VERSION" | sort -V | tail -n1)" - if [ "$PR_VERSION" = "$MAIN_VERSION" ] || [ "$HIGHEST" != "$PR_VERSION" ]; then - echo "::error::ooniauth-py version in $FILE must be strictly higher than main (main=$MAIN_VERSION, PR=$PR_VERSION). Otherwise the PyPi upload will fail after merge" - exit 1 - fi - - echo "[OK] Version bump detected (main=$MAIN_VERSION -> PR=$PR_VERSION)" diff --git a/.github/workflows/wheels-release.yml b/.github/workflows/wheels-release.yml index b3a43ee..4321b17 100644 --- a/.github/workflows/wheels-release.yml +++ b/.github/workflows/wheels-release.yml @@ -2,11 +2,8 @@ name: Build Wheels and Release on: push: - branches: - - "main" - paths: - - "ooniauth-core/**" - - "ooniauth-py/**" + tags: + - "userauth-*" workflow_dispatch: {} permissions: @@ -56,6 +53,9 @@ jobs: runs-on: ubuntu-latest needs: build-wheels steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Download wheel artifacts uses: actions/download-artifact@v4 with: @@ -68,18 +68,11 @@ jobs: echo "Wheel files in dist:" ls -la dist - - name: Compute release tag name - id: tag - run: | - SHORT_SHA="${GITHUB_SHA::7}" - TAG="ooniauth-py-${SHORT_SHA}-${GITHUB_RUN_NUMBER}" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - name: Upload wheels to GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.tag.outputs.tag }} - name: ${{ steps.tag.outputs.tag }} + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} target_commitish: ${{ github.sha }} generate_release_notes: true files: | @@ -90,7 +83,7 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - # password: ${{ secrets.TEST_PYPI_API_TOKEN }} # use this for testing the workflow - password: ${{ secrets.PYPI_API_TOKEN }} + password: ${{ secrets.TEST_PYPI_API_TOKEN }} # use this for testing the workflow + # password: ${{ secrets.PYPI_API_TOKEN }} packages-dir: dist - # repository-url: https://test.pypi.org/legacy/ # uncomment this to use the test api token + repository-url: https://test.pypi.org/legacy/ # uncomment this to use the test api token diff --git a/scripts/create-release-tag.py b/scripts/create-release-tag.py new file mode 100755 index 0000000..c40e926 --- /dev/null +++ b/scripts/create-release-tag.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +create and push userauth-- tags +Checks that the version in ooniauth-py is consistent before creating the new tag +""" + +import json +import re +import subprocess +import sys +import urllib.request +from pathlib import Path + +TAG_PREFIX = "userauth" +PYPI_JSON_URL = "https://pypi.org/pypi/ooniauth-py/json" + +CYAN = "\033[36m" +YELLOW = "\033[33m" +RED = "\033[31m" +RESET = "\033[0m" + +REPO_ROOT = Path(__file__).resolve().parent.parent + +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + +def git(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(REPO_ROOT), *args], + check=check, + capture_output=True, + text=True, + ) + +def version_tuple(v: str) -> tuple[int, ...]: + """Numeric dotted version -> tuple for lexicographic compare""" + parts = v.strip().split(".") + if not parts or any(not p.isdigit() for p in parts): + raise ValueError(f"expected dotted numeric version, got {v}") + return tuple(int(p) for p in parts) + + +def parse_cargo_version(cargo_toml: Path) -> str: + text = cargo_toml.read_text(encoding="utf-8") + m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + if not m: + raise SystemExit(f"Error: could not parse version from {cargo_toml}") + return m.group(1) + + +def fetch_pypi_version(url: str = PYPI_JSON_URL) -> str: + req = urllib.request.Request(url, headers={"User-Agent": "create-release-tag.py"}) + with urllib.request.urlopen(req, timeout=20) as response: + data = json.load(response) + return str(data["info"]["version"]) + + +def max_release_number(tags: list[str]) -> int: + pat = re.compile(rf"^{re.escape(TAG_PREFIX)}-[^-]+-(\d+)$") + best = 0 + for line in tags: + t = line.strip() + m = pat.match(t) + if m: + best = max(best, int(m.group(1))) + return best + + +def tag_exists_remote(tag: str) -> bool: + # ls-remote exits 0 with empty output when the ref is missing. + out = git( + "ls-remote", + "--tags", + "origin", + f"refs/tags/{tag}", + ) + return bool(out.stdout.strip()) + + +def main() -> None: + current_branch = git("rev-parse", "--abbrev-ref", "HEAD").stdout + commit_sha = git("rev-parse", "--short", "HEAD").stdout + + tags_out = git("tag", "--list", f"{TAG_PREFIX}-*").stdout + tags = [ln for ln in tags_out.splitlines() if ln.strip()] + max_n = max_release_number(tags) + next_n = max_n + 1 + tag_name = f"{TAG_PREFIX}-{commit_sha}-{next_n}" + + if git("rev-parse", tag_name, check = False).returncode != 0: + eprint(f"Error: tag {tag_name} already exists locally.") + sys.exit(1) + + if tag_exists_remote(tag_name): + eprint(f"Error: tag {tag_name} already exists on origin.") + sys.exit(1) + + cargo_toml = REPO_ROOT / "ooniauth-py" / "Cargo.toml" + if not cargo_toml.is_file(): + eprint(f"Error: Missing ooniauth-py cargo.toml file :{cargo_toml}") + sys.exit(1) + + local_version = parse_cargo_version(cargo_toml) + + print("Checking last version from PyPi...") + try: + pypi_version = fetch_pypi_version() + except Exception as e: + eprint(f"Error: failed to fetch PyPI version: {e}") + sys.exit(1) + + try: + local_version = version_tuple(local_version) + pypi_version = version_tuple(pypi_version) + except ValueError as e: + eprint(f"Error: {e}") + sys.exit(1) + + if local_version <= pypi_version: + eprint( + f"{RED}ERROR{RESET}: Local ooniauth-py version must be greater than PyPI, " + "bump ooniauth-py/Cargo.toml before releasing.", + ) + eprint(f" Local: {local_version}") + eprint(f" PyPI: {pypi_version}") + sys.exit(1) + + print("Version number OK!") + + print("Release tag preview:") + print(f" Tag scheme: {TAG_PREFIX}--") + print(f" Release number: {next_n}") + print(f" Tag: {CYAN}{tag_name}{RESET}") + if current_branch == "main": + print(f" Branch: {current_branch}") + else: + print(f" Branch: {RED}{current_branch} (WARNING: not main){RESET}") + print(f" Commit: {commit_sha}") + print(f" ooniauth-py: {local_version} (PyPI: {pypi_version})") + print() + if current_branch != "main": + print( + f" {YELLOW}WARNING: current branch is '{current_branch}' (not main): " + f"commit {commit_sha} is not in main{RESET}" + ) + + prompt = ( + f"Create and push tag {CYAN} {tag_name} {RESET} to origin? [y/N] " + ) + answer = input(prompt).strip().lower() + if answer not in ("y", "yes"): + print("Aborted.") + return + + git("tag", "-a", tag_name, "-m", f"Release {tag_name}") + git("push", "origin", tag_name) + print(f"Done: pushed {CYAN}{tag_name}{RESET}") + + +if __name__ == "__main__": + main()