Skip to content

ci: publish wheels as immutable GitHub releases - #834

Open
luhenry wants to merge 3 commits into
mainfrom
simple-api
Open

ci: publish wheels as immutable GitHub releases#834
luhenry wants to merge 3 commits into
mainfrom
simple-api

Conversation

@luhenry

@luhenry luhenry commented Sep 2, 2026

Copy link
Copy Markdown
Member

Replace GitLab publishing with a reusable release workflow and generate the /simple API from package metadata.

@luhenry
luhenry marked this pull request as ready for review September 2, 2026 12:07
@luhenry
luhenry marked this pull request as draft September 2, 2026 12:07
@luhenry

luhenry commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

I'm writing and testing the scripts that will download all previous releases to GitLab and publish them as GitHub releases

Replace GitLab publishing with a reusable release workflow and generate the Simple API from package YAML metadata.

Signed-off-by: Ludovic Henry <git@ludovic.dev>
@luhenry
luhenry marked this pull request as ready for review September 2, 2026 14:26
The script that was used to migrate it all is the following:

```
"""One-off GitLab PyPI registry -> immutable GitHub Releases migration.

Dry-run by default. Pass --execute to create releases and update docs/packages/*.yaml.
Requires: requests-cache, pyyaml, and an authenticated `gh` CLI with contents write.
The target repository must have immutable releases enabled.
"""

import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import time
from collections import defaultdict
from datetime import datetime, timezone
from email.parser import Parser
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin, urlparse
import zipfile

import requests_cache
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import yaml

GITLAB_PROJECT_ID = "56254198"
GITLAB_SIMPLE = (
    f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/packages/pypi/simple/"
)
GITLAB_PACKAGES = f"https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/packages"
GITLAB_PACKAGE_UI = "https://gitlab.com/riseproject/python/wheel_builder/-/packages"
REPO = "riseproject-dev/python-wheels"
DOCS = Path("docs/packages")
STATE = Path(".git/pw-scratch/release-migration/state.json")
REPORT = Path(".git/pw-scratch/release-migration/report.json")

def normalize(name):
    return re.sub(r"[-_.]+", "-", name).lower()

class Links(HTMLParser):
    def __init__(self):
        super().__init__()
        self.links = []
        self.current = None
        self.text = []

    def handle_starttag(self, tag, attrs):
        if tag == "a":
            self.current = dict(attrs)
            self.text = []

    def handle_data(self, data):
        if self.current is not None:
            self.text.append(data)

    def handle_endtag(self, tag):
        if tag == "a" and self.current is not None:
            self.links.append((self.current, "".join(self.text).strip()))
            self.current = None
            self.text = []

def get_links(session, url):
    response = session.get(url, timeout=(30, 300))
    response.raise_for_status()
    parser = Links()
    parser.feed(response.text)
    return parser.links

def get_package_ids(session):
    """Return the newest GitLab package ID for each normalized name/version."""
    package_ids = {}
    page = 1
    while True:
        response = session.get(
            GITLAB_PACKAGES,
            params={"package_type": "pypi", "per_page": 100, "page": page},
            timeout=(30, 300),
        )
        response.raise_for_status()
        packages = response.json()
        for package in packages:
            key = (normalize(package["name"]), str(package["version"]))
            package_ids[key] = max(package["id"], package_ids.get(key, 0))
        next_page = response.headers.get("X-Next-Page")
        if not next_page:
            break
        page = int(next_page)
    return package_ids

def run(*args, capture=False):
    return subprocess.run(args, check=True, text=True, capture_output=capture)

def wheel_requires_python(path):
    with zipfile.ZipFile(path) as archive:
        metadata_path = next(
            n for n in archive.namelist() if n.endswith(".dist-info/METADATA")
        )
        return (
            Parser()
            .parsestr(archive.read(metadata_path).decode())
            .get("Requires-Python")
        )

def load_state():
    if STATE.exists():
        return json.loads(STATE.read_text())
    return {
        "timestamp": datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"),
        "completed": {},
    }

def save_state(state):
    STATE.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")

def existing_gpl_sources(version_item):
    comment = version_item.get("comment")
    if not comment or "gpl-sources.tar" not in comment:
        return None
    match = re.search(r"(https://github\.com/[^>`]+/gpl-sources\.tar)", comment)
    if not match:
        return None
    description = re.search(r"bundled GPL libraries(?: \(([^)]+)\))?", comment)
    result = {"url": match.group(1), "filename": "gpl-sources.tar"}
    if description and description.group(1):
        result["description"] = description.group(1)
    return result

def add_yaml_release(package, version, release):
    path = DOCS / f"{package}.yaml"
    if not path.exists():
        raise RuntimeError(f"missing package YAML: {path}")
    data = yaml.safe_load(path.read_text())
    item = next(
        (v for v in data.get("versions", []) if str(v.get("version")) == version),
        None,
    )
    if item is None:
        item = {"version": version}
        data.setdefault("versions", []).append(item)
    if release.get("gpl-sources"):
        item.pop("comment", None)
    releases = item.setdefault("releases", [])
    if not any(r.get("tag") == release["tag"] for r in releases):
        releases.append(release)
    path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True))

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--execute", action="store_true")
    args = parser.parse_args()
    state = load_state()
    session = requests_cache.CachedSession(
        cache_name="/tmp/cache/gitlab-package-migration",
        backend="filesystem",
        expire_after=-1,
    )
    gitlab_token = os.environ.get("GITLAB_TOKEN")
    if gitlab_token:
        session.headers.update({"PRIVATE-TOKEN": gitlab_token})
    retries = Retry(
        total=8, backoff_factor=2, status_forcelist=(429, 500, 502, 503, 504)
    )
    session.mount("https://", HTTPAdapter(max_retries=retries))

    package_ids = get_package_ids(session)
    root_links = get_links(session, GITLAB_SIMPLE)
    package_urls = {}
    for attrs, text in root_links:
        href = attrs.get("href")
        if href and text:
            package_urls[normalize(text)] = urljoin(GITLAB_SIMPLE, href)

    groups = defaultdict(list)

    for number, (package, package_url) in enumerate(sorted(package_urls.items()), 1):
        print(f"[{number}/{len(package_urls)}] indexing {package}")
        for attrs, filename in get_links(session, package_url):
            if not filename.endswith(".whl"):
                continue
            href = urljoin(package_url, attrs["href"])
            groups[package].append((href, filename, attrs.get("data-requires-python")))

    report = {
        "packages": len(package_urls),
        "package_pages": len(groups),
        "files": sum(map(len, groups.values())),
        "completed": [],
    }
    print(json.dumps(report, indent=2))

    if args.execute:
        immutable = json.loads(
            run("gh", "api", f"repos/{REPO}/immutable-releases", capture=True).stdout
        )
        if not immutable.get("enabled"):
            raise RuntimeError("immutable releases are not enabled")

    yaml_packages = {
        normalize(yaml.safe_load(path.read_text())["package-name"])
        for path in DOCS.glob("*.yaml")
    }
    missing_from_gitlab = sorted(yaml_packages - package_urls.keys())
    if missing_from_gitlab:
        raise RuntimeError(
            "YAML packages absent from GitLab simple index: "
            + ", ".join(missing_from_gitlab)
        )
    report["yaml_packages"] = len(yaml_packages)
    report["groups"] = 0

    for number, (package, links) in enumerate(groups.items(), 1):
        print(f"[{number}/{len(groups)}] processing {len(links)} files for {package}")
        version_groups = defaultdict(dict)

        with tempfile.TemporaryDirectory(
            dir=".git/pw-scratch/release-migration"
        ) as tmp:
            tmp = Path(tmp)

            for download_number, (url, filename, requires_python) in enumerate(
                links, 1
            ):
                print(f"downloading {filename}")
                target = tmp / package / "downloads" / str(download_number) / filename
                target.parent.mkdir(parents=True, exist_ok=True)
                with session.get(url, stream=True, timeout=(30, 600)) as response:
                    response.raise_for_status()
                    with target.open("wb") as stream:
                        for chunk in response.iter_content(1024 * 1024):
                            stream.write(chunk)

                with zipfile.ZipFile(target) as archive:
                    metadata_path = next(
                        name
                        for name in archive.namelist()
                        if name.endswith(".dist-info/METADATA")
                    )
                    message = Parser().parsestr(archive.read(metadata_path).decode())
                project, version = message.get("Name"), message.get("Version")
                if normalize(project) != package:
                    raise RuntimeError(f"package mismatch: {package}: {filename}")

                digest = hashlib.sha256(target.read_bytes()).hexdigest()
                fragment = urlparse(url).fragment
                if fragment.startswith("sha256=") and digest != fragment.removeprefix(
                    "sha256="
                ):
                    raise RuntimeError(f"digest mismatch: {filename}")

                file_data = {"filename": filename, "sha256": digest}
                requires = message.get("Requires-Python") or requires_python
                if requires:
                    file_data["requires-python"] = requires

                previous = version_groups[version].get(filename)
                if previous and previous["metadata"]["sha256"] != digest:
                    raise RuntimeError(f"conflicting duplicate filename: {filename}")
                if previous:
                    previous["path"].unlink()
                version_groups[version][filename] = {
                    "metadata": file_data,
                    "path": target,
                }

            report["groups"] += len(version_groups)

            for version, file_map in sorted(version_groups.items()):
                key = f"{package}=={version}"
                paths = [entry["path"] for entry in file_map.values()]
                try:
                    if key in state["completed"]:
                        print(f"already completed {key}")
                        continue

                    tag = f"{package}-v{version}-{state['timestamp']}"
                    title = f"{package}-v{version}"
                    files = [entry["metadata"] for entry in file_map.values()]

                    if not args.execute:
                        print(
                            f"validated {key} ({len(files)} files); would create {tag}"
                        )
                        continue

                    yaml_data = yaml.safe_load((DOCS / f"{package}.yaml").read_text())
                    version_item = next(
                        (
                            item
                            for item in yaml_data.get("versions", [])
                            if str(item.get("version")) == version
                        ),
                        {},
                    )
                    gpl_sources = existing_gpl_sources(version_item)
                    if gpl_sources:
                        source = tmp / package / version / gpl_sources["filename"]
                        source.parent.mkdir(parents=True, exist_ok=True)
                        with session.get(
                            gpl_sources["url"], stream=True, timeout=(30, 600)
                        ) as response:
                            response.raise_for_status()
                            with source.open("wb") as stream:
                                for chunk in response.iter_content(1024 * 1024):
                                    stream.write(chunk)
                        paths.append(source)

                    gitlab_package_id = package_ids.get((package, version))
                    if gitlab_package_id is None:
                        raise RuntimeError(
                            f"missing GitLab package ID for {package}=={version}"
                        )
                    gitlab_package_url = f"{GITLAB_PACKAGE_UI}/{gitlab_package_id}"
                    migrated_at = datetime.strptime(
                        state["timestamp"], "%Y%m%d%H%M%S"
                    ).replace(tzinfo=timezone.utc)
                    notes = (
                        f"Migrated from the [GitLab package]"
                        f"({gitlab_package_url}) on "
                        f"{migrated_at.strftime('%Y-%m-%d %H:%M:%S UTC')}."
                    )
                    existing = (
                        subprocess.run(
                            ["gh", "release", "view", tag, "--repo", REPO],
                            capture_output=True,
                            text=True,
                        ).returncode
                        == 0
                    )
                    if not existing:
                        run(
                            "gh",
                            "release",
                            "create",
                            tag,
                            "--repo",
                            REPO,
                            "--target",
                            "79acd1ea9648523a330db1767e9d645f14f51d5d",
                            "--title",
                            title,
                            "--notes",
                            notes,
                            "--draft",
                            "--latest=false",
                        )
                        run(
                            "gh",
                            "release",
                            "upload",
                            tag,
                            *map(str, paths),
                            "--repo",
                            REPO,
                        )
                        run(
                            "gh",
                            "release",
                            "edit",
                            tag,
                            "--repo",
                            REPO,
                            "--draft=false",
                            "--latest=false",
                        )

                    release = {
                        "tag": tag,
                        "files": sorted(files, key=lambda item: item["filename"]),
                    }
                    if gpl_sources:
                        release["gpl-sources"] = {
                            key: value
                            for key, value in gpl_sources.items()
                            if key != "url"
                        }
                    add_yaml_release(package, version, release)
                    state["completed"][key] = {"tag": tag, "files": len(files)}
                    save_state(state)
                    report["completed"].append(key)
                    print(f"published {tag} ({len(files)} files)")
                finally:
                    for path in paths:
                        path.unlink(missing_ok=True)

    REPORT.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")

    if not args.execute:
        print("Dry run complete; pass --execute to publish releases and update YAML.")

if __name__ == "__main__":
    try:
        main()
    except Exception as error:
        print(f"error: {error}", file=sys.stderr)
        raise
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant