diff --git a/.github/workflows/build_rocm_transformer_engine_wheel_weekly.yml b/.github/workflows/build_rocm_transformer_engine_wheel_weekly.yml index d0bd1c6c7..515a3d63c 100644 --- a/.github/workflows/build_rocm_transformer_engine_wheel_weekly.yml +++ b/.github/workflows/build_rocm_transformer_engine_wheel_weekly.yml @@ -11,8 +11,8 @@ permissions: jobs: build_upload_prune: - # Same runner label used by ROCm test workflows. - runs-on: ["self-hosted", "linux-x86-64-4gpu-amd"] + # AMD GPU runner (GitHub-hosted large runner label). + runs-on: linux-x86-64-4gpu-amd container: image: ghcr.io/rocm/jax-base-ubu24.rocm720:latest options: >- @@ -28,10 +28,8 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Build & publish TE wheels (native arch then MI300) + - name: Setup build environment (deps + venv) shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail apt-get update @@ -40,33 +38,72 @@ jobs: python3 -m uv venv --seed source .venv/bin/activate uv pip install -U pip setuptools wheel pybind11 cmake - # Install ROCm JAX/JAXlib wheels into the venv (so TE builds against the same stack as CI). + + - name: Install ROCm JAX/JAXlib wheels (build against CI stack) + shell: bash + run: | + set -euo pipefail + source .venv/bin/activate uv pip install -r dependencies/requirements/requirements_decoupled_rocm_jax_0_8_2.txt - # Detect runner ROCm architecture (avoid generic tokens like gfx9). - PRIMARY_ARCH="$( - (command -v rocminfo >/dev/null 2>&1 && rocminfo || /opt/rocm/bin/rocminfo) \ - | grep -oE 'gfx[0-9a-f]+' \ - | sort -u \ - | awk '{ print length($0), $0 }' \ - | sort -nr \ - | head -n1 \ - | awk '{ print $2 }' - )" - echo "Detected runner ROCm arch: ${PRIMARY_ARCH}" + - name: Detect ROCm version and Python tag + shell: bash + run: | + set -euo pipefail + source .venv/bin/activate # Detect ROCm version number from JAX backend string when available (e.g. 'rocm 70200'). ROCM_NUM="$(python3 -c 'import re, jax; s=str(jax.devices()[0].client.platform_version); m=re.search(r"rocm\\s+([0-9]+)", s); print(m.group(1) if m else "unknown")')" echo "Detected ROCm version: ${ROCM_NUM}" + echo "ROCM_NUM=${ROCM_NUM}" >> "${GITHUB_ENV}" - # Build TE from ROCm/TransformerEngine dev branch. + PYTAG="cp$(python3 -c 'import sys; print(f"{sys.version_info.major}{sys.version_info.minor}")')" + if [ "${PYTAG}" != "cp312" ]; then + echo "Expected Python 3.12 (cp312) for ROCm CI wheels, got ${PYTAG}." + exit 1 + fi + echo "PYTAG=${PYTAG}" >> "${GITHUB_ENV}" + echo "REL_SCRIPT=.github/workflows/utils/te_wheels_release.py" >> "${GITHUB_ENV}" + + - name: Clone ROCm/TransformerEngine (dev) + shell: bash + run: | + set -euo pipefail rm -rf TransformerEngine git clone --recursive --branch dev https://github.com/ROCm/TransformerEngine.git cd TransformerEngine git submodule update --init --recursive - TE_SHA="$(git rev-parse --short=12 HEAD)" - PYTAG="cp$(python3 -c 'import sys; print(f"{sys.version_info.major}{sys.version_info.minor}")')" + echo "TE_SHA=${TE_SHA}" >> "${GITHUB_ENV}" + + - name: Select TE wheel arch for runner (mi300/mi355) + shell: bash + run: | + set -euo pipefail + source .venv/bin/activate + + TE_WHEEL_ARCH="$(python3 .github/workflows/utils/install_te_rocm_wheel.py --print-arch)" + echo "Resolved TE wheel arch for runner: ${TE_WHEEL_ARCH}" + echo "TE_WHEEL_ARCH=${TE_WHEEL_ARCH}" >> "${GITHUB_ENV}" + + # Build ONLY for the ROCm arch present on this CI runner (mi300 or mi355). + if [ "${TE_WHEEL_ARCH}" = "mi355" ]; then + SELECTOR="mi355" + GFX_ARCH="gfx950" + else + SELECTOR="mi300" + GFX_ARCH="gfx942;gfx941" + fi + echo "SELECTOR=${SELECTOR}" >> "${GITHUB_ENV}" + echo "GFX_ARCH=${GFX_ARCH}" >> "${GITHUB_ENV}" + + - name: Build TE wheel + shell: bash + run: | + set -euo pipefail + source .venv/bin/activate + + chmod +x "${REL_SCRIPT}" || true export USE_ROCM=1 export HIP_PATH=/opt/rocm @@ -75,239 +112,81 @@ jobs: export NVTE_USE_ROCM=1 export NVTE_FUSED_ATTN_AOTRITON=0 export NVTE_BUILD_MAX_JOBS=180 + #export NVTE_AITER_PREBUILT_BASE_URL=https://compute-artifactory.amd.com:5000/artifactory/rocm-generic-local/te-ci/aiter-prebuilts + + echo "=== Building TE wheel for ${SELECTOR} (gfx=${GFX_ARCH}) ===" + pushd TransformerEngine >/dev/null + rm -rf build dist + export PYTHONPATH="$(pwd)/3rdparty/hipify_torch${PYTHONPATH:+:${PYTHONPATH}}" + export PYTORCH_ROCM_ARCH="${GFX_ARCH}" + export NVTE_ROCM_ARCH="${GFX_ARCH}" + python3 setup.py bdist_wheel + wheel_path="$( + python3 -c 'import glob; m=sorted(glob.glob("dist/transformer_engine-*.whl")); print(m[0] if m else "")' + )" + if [ -z "${wheel_path}" ]; then + echo "No wheel produced in dist/ (selector=${SELECTOR})." + exit 1 + fi + wheel_base="$(basename "${wheel_path}")" + if [[ "${wheel_base}" == *"-1.${SELECTOR}-${PYTAG}-${PYTAG}-linux_x86_64.whl" ]]; then + asset_name="${wheel_base}" + else + asset_name="${wheel_base/-${PYTAG}-${PYTAG}-linux_x86_64.whl/-1.${SELECTOR}-${PYTAG}-${PYTAG}-linux_x86_64.whl}" + if [ "${asset_name}" = "${wheel_base}" ]; then + echo "Failed to rename wheel for selector=${SELECTOR}: ${wheel_base}" + exit 1 + fi + fi + cp -f "${wheel_path}" "../${asset_name}" + popd >/dev/null + + ls -lh "${asset_name}" + echo "TE_WHEEL_FILE=${asset_name}" >> "${GITHUB_ENV}" + + - name: Upload wheel to rolling release tag (te-rocm-wheels) + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + source .venv/bin/activate + python3 "${REL_SCRIPT}" upload --no-prerelease --tag "te-rocm-wheels" --title "ROCm TransformerEngine wheels (latest)" --body "Rolling release for latest weekly-built ROCm TransformerEngine wheels used by CI." --file "${TE_WHEEL_FILE}" - # Return to workspace root; build function will enter TE directory. - cd .. - - build_one() { - local arch="$1" - echo "=== Building TE wheel for ${arch} ===" - pushd TransformerEngine >/dev/null - rm -rf build dist - export PYTORCH_ROCM_ARCH="${arch}" - export NVTE_ROCM_ARCH="${arch}" - python3 setup.py bdist_wheel - local wheel_path - wheel_path="$(ls -1 dist/transformer_engine-*.whl | head -n1)" - popd >/dev/null - local asset_name - asset_name="transformer_engine-${TE_SHA}-${PYTAG}-rocm${ROCM_NUM}-${arch}.whl" - cp -f "TransformerEngine/${wheel_path}" "${asset_name}" - echo "${asset_name}" - } - - publish_asset_to_release_tag() { - local tag="$1" - local title="$2" - local body="$3" - local wheel_file="$4" - python3 - "$tag" "$title" "$body" "$wheel_file" <<'PY' - import json, os, sys, urllib.error, urllib.parse, urllib.request - - tag, title, body, wheel_file = sys.argv[1:5] - token = os.environ["GITHUB_TOKEN"] - repo = os.environ.get("GITHUB_REPOSITORY") - if not repo: - raise SystemExit("GITHUB_REPOSITORY is not set") - owner, name = repo.split("/", 1) - - api = "https://api.github.com" - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "User-Agent": "maxtext-ci", - } - - def request_json(method: str, url: str, body_obj=None): - data = None - if body_obj is not None: - data = json.dumps(body_obj).encode("utf-8") - req = urllib.request.Request(url, data=data, method=method, headers=headers) - with urllib.request.urlopen(req) as r: - return json.loads(r.read().decode("utf-8")) - - def request_raw(method: str, url: str, data: bytes, extra_headers=None): - h = dict(headers) - if extra_headers: - h.update(extra_headers) - req = urllib.request.Request(url, data=data, method=method, headers=h) - with urllib.request.urlopen(req) as r: - return r.read() - - # Ensure release exists for this tag. - release = None - try: - release = request_json("GET", f"{api}/repos/{owner}/{name}/releases/tags/{tag}") - except urllib.error.HTTPError as e: - if e.code != 404: - raise - - if release is None: - release = request_json( - "POST", - f"{api}/repos/{owner}/{name}/releases", - { - "tag_name": tag, - "name": title, - "body": body, - "prerelease": True, - }, - ) - - release_id = release["id"] - upload_url = release["upload_url"].split("{", 1)[0] - - # Delete any existing asset with same name. - assets = request_json("GET", f"{api}/repos/{owner}/{name}/releases/{release_id}")["assets"] - wheel_name = os.path.basename(wheel_file) - for a in assets: - if a.get("name") == wheel_name: - request_json("DELETE", f"{api}/repos/{owner}/{name}/releases/assets/{a['id']}") - - with open(wheel_file, "rb") as f: - wheel_bytes = f.read() - up = f"{upload_url}?{urllib.parse.urlencode({'name': wheel_name})}" - request_raw("POST", up, wheel_bytes, extra_headers={"Content-Type": "application/octet-stream"}) - print(f"Uploaded {wheel_name} to release tag {tag}", flush=True) - PY - } - - prune_old_weekly_releases() { - local prefix="$1" - local keep_days="$2" - python3 - "$prefix" "$keep_days" <<'PY' - import datetime as dt - import json, os, sys, urllib.request - - prefix = sys.argv[1] - keep_days = int(sys.argv[2]) - token = os.environ["GITHUB_TOKEN"] - repo = os.environ.get("GITHUB_REPOSITORY") - if not repo: - raise SystemExit("GITHUB_REPOSITORY is not set") - - api = "https://api.github.com" - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "User-Agent": "maxtext-ci", - } - - def get_json(url: str): - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req) as r: - return json.loads(r.read().decode("utf-8")) - - def delete(url: str): - req = urllib.request.Request(url, method="DELETE", headers=headers) - with urllib.request.urlopen(req): - return - - cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=keep_days) - - # Paginate releases. - page = 1 - deleted = 0 - while True: - releases = get_json(f"{api}/repos/{repo}/releases?per_page=100&page={page}") - if not releases: - break - for rel in releases: - tag = rel.get("tag_name", "") - if not tag.startswith(prefix): - continue - created = dt.datetime.fromisoformat(rel["created_at"].replace("Z", "+00:00")) - if created < cutoff: - delete(f"{api}/repos/{repo}/releases/{rel['id']}") - deleted += 1 - print(f"Deleted old weekly TE release {tag} (created_at={rel['created_at']})", flush=True) - page += 1 - print(f"Weekly TE release prune complete. Deleted {deleted} releases older than {keep_days} days.", flush=True) - PY - } - - prune_old_assets_in_release_tag() { - local tag="$1" - local keep_days="$2" - python3 - "$tag" "$keep_days" <<'PY' - import datetime as dt - import json, os, sys, urllib.error, urllib.request - - tag = sys.argv[1] - keep_days = int(sys.argv[2]) - token = os.environ["GITHUB_TOKEN"] - repo = os.environ.get("GITHUB_REPOSITORY") - if not repo: - raise SystemExit("GITHUB_REPOSITORY is not set") - owner, name = repo.split("/", 1) - - api = "https://api.github.com" - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "User-Agent": "maxtext-ci", - } - - def request_json(method: str, url: str): - req = urllib.request.Request(url, method=method, headers=headers) - with urllib.request.urlopen(req) as r: - data = r.read() - return json.loads(data.decode("utf-8")) if data else None - - def delete(url: str): - req = urllib.request.Request(url, method="DELETE", headers=headers) - with urllib.request.urlopen(req): - return - - try: - rel = request_json("GET", f"{api}/repos/{owner}/{name}/releases/tags/{tag}") - except urllib.error.HTTPError as e: - if e.code == 404: - print(f"No release for tag {tag}; skipping asset prune.", flush=True) - raise SystemExit(0) - raise - - cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=keep_days) - pruned = 0 - for a in rel.get("assets", []): - created = dt.datetime.fromisoformat(a["created_at"].replace("Z", "+00:00")) - if created < cutoff: - delete(f"{api}/repos/{owner}/{name}/releases/assets/{a['id']}") - pruned += 1 - print(f"Pruned old asset: {a['name']} (created_at={a['created_at']})", flush=True) - print(f"Asset prune complete for {tag}. Deleted {pruned} assets older than {keep_days} days.", flush=True) - PY - } - - # Build runner-native wheel and upload immediately so other CI can pick it up. - NATIVE_WHEEL="$(build_one "${PRIMARY_ARCH}")" - ls -lh "${NATIVE_WHEEL}" - publish_asset_to_release_tag \ - "te-rocm-wheels" \ - "ROCm TransformerEngine wheels (latest)" \ - "Rolling release for latest weekly-built ROCm TransformerEngine wheels used by CI." \ - "${NATIVE_WHEEL}" - - # Build MI300 wheel (gfx942) and upload. - MI300_WHEEL="$(build_one "gfx942")" - ls -lh "${MI300_WHEEL}" - publish_asset_to_release_tag \ - "te-rocm-wheels" \ - "ROCm TransformerEngine wheels (latest)" \ - "Rolling release for latest weekly-built ROCm TransformerEngine wheels used by CI." \ - "${MI300_WHEEL}" + - name: Prune old assets from rolling tag + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + source .venv/bin/activate + python3 "${REL_SCRIPT}" prune-assets --tag "te-rocm-wheels" --keep-days 21 - # Retention: prune rolling release assets older than 3 weeks. - prune_old_assets_in_release_tag "te-rocm-wheels" 21 + - name: Publish wheel to dated weekly release tag + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + source .venv/bin/activate - # Also publish both wheels to a new weekly release page. DATE_UTC="$(date -u +%Y-%m-%d)" WEEKLY_TAG="te-rocm-wheels-${DATE_UTC}-${TE_SHA}" WEEKLY_TITLE="ROCm TransformerEngine wheels ${DATE_UTC} (TE ${TE_SHA})" - WEEKLY_BODY="Built from ROCm/TransformerEngine dev @ ${TE_SHA} on ${DATE_UTC}.\nROCm=${ROCM_NUM}, Python=${PYTAG}, arches=${PRIMARY_ARCH} and gfx942." + # Keep this YAML-safe (no unindented heredocs inside `run: |`). + WEEKLY_BODY="$( + printf '%s\n\nROCm: %s\nPython: %s\nArch: %s (gfx=%s)\n' \ + "Built from ROCm/TransformerEngine dev @ ${TE_SHA} on ${DATE_UTC}." \ + "${ROCM_NUM}" "${PYTAG}" "${SELECTOR}" "${GFX_ARCH}" + )" - publish_asset_to_release_tag "${WEEKLY_TAG}" "${WEEKLY_TITLE}" "${WEEKLY_BODY}" "${NATIVE_WHEEL}" - publish_asset_to_release_tag "${WEEKLY_TAG}" "${WEEKLY_TITLE}" "${WEEKLY_BODY}" "${MI300_WHEEL}" + python3 "${REL_SCRIPT}" upload --no-prerelease --tag "${WEEKLY_TAG}" --title "${WEEKLY_TITLE}" --body "${WEEKLY_BODY}" --file "${TE_WHEEL_FILE}" - # Retention: delete weekly releases older than 3 weeks. - prune_old_weekly_releases "te-rocm-wheels-" 21 + - name: Prune old weekly releases + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + source .venv/bin/activate + python3 "${REL_SCRIPT}" prune-releases --prefix "te-rocm-wheels-" --keep-days 21 diff --git a/.github/workflows/utils/install_te_rocm_wheel.py b/.github/workflows/utils/install_te_rocm_wheel.py index 4d119f451..673d8f731 100644 --- a/.github/workflows/utils/install_te_rocm_wheel.py +++ b/.github/workflows/utils/install_te_rocm_wheel.py @@ -91,7 +91,6 @@ def try_download_from_te_rocm_wheels(repo: str, arch: str) -> bool: rel = json.loads(r.read().decode("utf-8")) assets = rel.get("assets", []) - # Wheels published by this repo use the selector format: `-1.-...` (e.g. `-1.mi355-...`). name_re = re.compile(rf"^transformer_engine-.*-1\.{arch}-cp312-cp312-linux_x86_64\.whl$") hit = next((a for a in assets if name_re.match(a.get("name", ""))), None) if not hit: diff --git a/.github/workflows/utils/te_wheels_release.py b/.github/workflows/utils/te_wheels_release.py new file mode 100644 index 000000000..a2ae1368e --- /dev/null +++ b/.github/workflows/utils/te_wheels_release.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +Publish and prune ROCm TransformerEngine wheel assets on GitHub Releases. + +Intended for use in GitHub Actions. Requires: +- GITHUB_TOKEN +- GITHUB_REPOSITORY (owner/repo) +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +API = "https://api.github.com" + + +def _env_token() -> str: + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise SystemExit("GITHUB_TOKEN is not set.") + return token + + +def _env_repo() -> tuple[str, str]: + repo = os.environ.get("GITHUB_REPOSITORY") + if not repo or "/" not in repo: + raise SystemExit("GITHUB_REPOSITORY is not set (expected 'owner/repo').") + owner, name = repo.split("/", 1) + return owner, name + + +def _headers(token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "User-Agent": "maxtext-ci", + } + + +def _request_json(method: str, url: str, token: str, body: dict | None = None): + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, method=method, headers=_headers(token)) + with urllib.request.urlopen(req) as r: + raw = r.read() + return json.loads(raw.decode("utf-8")) if raw else None + + +def _request_raw(method: str, url: str, token: str, data: bytes, content_type: str): + h = _headers(token) + h["Content-Type"] = content_type + req = urllib.request.Request(url, data=data, method=method, headers=h) + with urllib.request.urlopen(req) as r: + return r.read() + + +def get_or_create_release(tag: str, title: str, body: str, prerelease: bool) -> dict: + """Get a release by tag, or create it if missing. + + Args: + tag: Release tag (e.g. 'te-rocm-wheels'). + title: Release title. + body: Release body text. + prerelease: Whether to mark the release as a prerelease. + + Returns: + The GitHub release object JSON. + """ + token = _env_token() + owner, name = _env_repo() + try: + rel = _request_json("GET", f"{API}/repos/{owner}/{name}/releases/tags/{tag}", token) + if rel: + return rel + except urllib.error.HTTPError as e: + if e.code != 404: + raise + return _request_json( + "POST", + f"{API}/repos/{owner}/{name}/releases", + token, + {"tag_name": tag, "name": title, "body": body, "prerelease": prerelease}, + ) + + +def upload_asset(tag: str, title: str, body: str, file_path: str, prerelease: bool) -> None: + """Upload (replace) a release asset under the given tag. + + If an asset with the same filename already exists, it is deleted first. + """ + token = _env_token() + owner, name = _env_repo() + + rel = get_or_create_release(tag, title, body, prerelease) + release_id = rel["id"] + upload_url = rel["upload_url"].split("{", 1)[0] + + assets = _request_json("GET", f"{API}/repos/{owner}/{name}/releases/{release_id}", token)["assets"] + file_name = os.path.basename(file_path) + for a in assets: + if a.get("name") == file_name: + _request_json("DELETE", f"{API}/repos/{owner}/{name}/releases/assets/{a['id']}", token) + + with open(file_path, "rb") as f: + data = f.read() + up = f"{upload_url}?{urllib.parse.urlencode({'name': file_name})}" + _request_raw("POST", up, token, data, "application/octet-stream") + print(f"Uploaded {file_name} to release tag {tag}", flush=True) + + +def prune_assets(tag: str, keep_days: int) -> None: + """Delete assets older than `keep_days` from the given release tag.""" + token = _env_token() + owner, name = _env_repo() + try: + rel = _request_json("GET", f"{API}/repos/{owner}/{name}/releases/tags/{tag}", token) + except urllib.error.HTTPError as e: + if e.code == 404: + print(f"No release for tag {tag}; skipping asset prune.", flush=True) + return + raise + + cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=keep_days) + pruned = 0 + for a in rel.get("assets", []): + created = dt.datetime.fromisoformat(a["created_at"].replace("Z", "+00:00")) + if created < cutoff: + _request_json("DELETE", f"{API}/repos/{owner}/{name}/releases/assets/{a['id']}", token) + pruned += 1 + print(f"Pruned old asset: {a['name']} (created_at={a['created_at']})", flush=True) + print(f"Asset prune complete for {tag}. Deleted {pruned} assets older than {keep_days} days.", flush=True) + + +def prune_releases(prefix: str, keep_days: int) -> None: + """Delete releases with tag names starting with `prefix` older than `keep_days` days.""" + token = _env_token() + owner, name = _env_repo() + cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=keep_days) + + page = 1 + deleted = 0 + while True: + rels = _request_json("GET", f"{API}/repos/{owner}/{name}/releases?per_page=100&page={page}", token) + if not rels: + break + for rel in rels: + tag = rel.get("tag_name", "") + if not tag.startswith(prefix): + continue + created = dt.datetime.fromisoformat(rel["created_at"].replace("Z", "+00:00")) + if created < cutoff: + _request_json("DELETE", f"{API}/repos/{owner}/{name}/releases/{rel['id']}", token) + deleted += 1 + print(f"Deleted old release {tag} (created_at={rel['created_at']})", flush=True) + page += 1 + print(f"Release prune complete. Deleted {deleted} releases older than {keep_days} days.", flush=True) + + +def main(argv: list[str]) -> int: + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="cmd", required=True) + + up = sub.add_parser("upload") + up.add_argument("--tag", required=True) + up.add_argument("--title", required=True) + up.add_argument("--body", required=True) + up.add_argument("--file", required=True) + prg = up.add_mutually_exclusive_group() + prg.add_argument("--prerelease", dest="prerelease", action="store_true") + prg.add_argument("--no-prerelease", dest="prerelease", action="store_false") + up.set_defaults(prerelease=True) + + pa = sub.add_parser("prune-assets") + pa.add_argument("--tag", required=True) + pa.add_argument("--keep-days", type=int, required=True) + + pr = sub.add_parser("prune-releases") + pr.add_argument("--prefix", required=True) + pr.add_argument("--keep-days", type=int, required=True) + + args = p.parse_args(argv) + + if args.cmd == "upload": + upload_asset(args.tag, args.title, args.body, args.file, prerelease=args.prerelease) + return 0 + if args.cmd == "prune-assets": + prune_assets(args.tag, args.keep_days) + return 0 + if args.cmd == "prune-releases": + prune_releases(args.prefix, args.keep_days) + return 0 + raise AssertionError("unreachable") + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))