diff --git a/.github/workflows/publish-versions.yml b/.github/workflows/publish-versions.yml new file mode 100644 index 000000000..be6d29449 --- /dev/null +++ b/.github/workflows/publish-versions.yml @@ -0,0 +1,104 @@ +# Publish python-build-standalone version information to the versions repository. +name: publish-versions + +on: + workflow_call: + inputs: + tag: + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish (e.g. 20260127)" + required: true + type: string + dry-run: + description: "Only generate metadata, skip PR creation" + required: false + type: boolean + default: true + +permissions: {} + +jobs: + publish-versions: + runs-on: ubuntu-latest + env: + TAG: ${{ inputs.tag }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: "Install uv" + uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + + - name: "Download SHA256SUMS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p dist + gh release download "$TAG" --dir dist --pattern "SHA256SUMS" + + - name: "Generate versions metadata" + env: + GITHUB_EVENT_INPUTS_TAG: ${{ inputs.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: uv run generate-version-metadata.py > dist/versions.ndjson + + - name: "Validate metadata" + run: | + echo "Generated $(wc -l < dist/versions.ndjson) version entries" + head -c 1000 dist/versions.ndjson + + - name: "Set branch name" + if: inputs.dry-run != true + run: echo "BRANCH_NAME=update-versions-$TAG-$(date +%s)" >> $GITHUB_ENV + + - name: "Clone versions repo" + if: inputs.dry-run != true + run: git clone https://${{ secrets.ASTRAL_VERSIONS_PAT }}@github.com/astral-sh/versions.git astral-versions + + - name: "Update versions" + if: inputs.dry-run != true + run: cat dist/versions.ndjson | uv run astral-versions/scripts/insert-versions.py --name python-build-standalone + + - name: "Commit versions" + if: inputs.dry-run != true + working-directory: astral-versions + run: | + git config user.name "astral-versions-bot" + git config user.email "176161322+astral-versions-bot@users.noreply.github.com" + + git checkout -b "$BRANCH_NAME" + git add -A + git commit -m "Update python-build-standalone to $TAG" + + - name: "Create Pull Request" + if: inputs.dry-run != true + working-directory: astral-versions + env: + GITHUB_TOKEN: ${{ secrets.ASTRAL_VERSIONS_PAT }} + run: | + pull_request_title="Update python-build-standalone versions for $TAG" + + gh pr list --state open --json title --jq ".[] | select(.title == \"$pull_request_title\") | .number" | \ + xargs -I {} gh pr close {} + + git push origin "$BRANCH_NAME" + + gh pr create --base main --head "$BRANCH_NAME" \ + --title "$pull_request_title" \ + --body "Automated versions update for $TAG" \ + --label "automation" + + - name: "Merge Pull Request" + if: inputs.dry-run != true + working-directory: astral-versions + env: + GITHUB_TOKEN: ${{ secrets.ASTRAL_VERSIONS_PAT }} + run: | + # Wait for PR to be created before merging + sleep 10 + gh pr merge --squash "$BRANCH_NAME" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ec62442e..01a7c3d7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,3 +97,11 @@ jobs: subject-path: | dist/*.tar.gz dist/*.tar.zst + + publish-versions: + needs: release + if: ${{ github.event.inputs.dry-run == 'false' }} + uses: ./.github/workflows/publish-versions.yml + with: + tag: ${{ github.event.inputs.tag }} + secrets: inherit diff --git a/generate-version-metadata.py b/generate-version-metadata.py new file mode 100755 index 000000000..48582917e --- /dev/null +++ b/generate-version-metadata.py @@ -0,0 +1,100 @@ +# /// script +# requires-python = ">=3.11" +# /// +"""Generate versions payload for python-build-standalone releases.""" + +from __future__ import annotations + +import json +import os +import re +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote + +FILENAME_RE = re.compile( + r"""(?x) + ^ + cpython- + (?P\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?)(?:\+\d+)?\+ + (?P\d+)- + (?P[a-z\d_]+-[a-z\d]+(?:-[a-z\d]+)?-[a-z\d_]+)- + (?:(?P.+)-)? + (?P[a-z_]+)? + \.tar\.(?:gz|zst) + $ + """ +) + + +def main() -> None: + tag = os.environ["GITHUB_EVENT_INPUTS_TAG"] + repo = os.environ["GITHUB_REPOSITORY"] + dist = Path("dist") + checksums = dist / "SHA256SUMS" + + if not checksums.exists(): + raise SystemExit("SHA256SUMS not found in dist/") + + # Parse filenames and checksums directly from SHA256SUMS to avoid downloading + # all release artifacts (tens of GB). + entries: list[tuple[str, str]] = [] + for line in checksums.read_text().splitlines(): + line = line.strip() + if not line: + continue + checksum, filename = line.split(maxsplit=1) + filename = filename.lstrip("*") + entries.append((filename, checksum)) + + versions: dict[str, list[dict[str, str]]] = defaultdict(list) + for filename, checksum in sorted(entries): + match = FILENAME_RE.match(filename) + if match is None: + continue + python_version = match.group("py") + build_version = match.group("tag") + version = f"{python_version}+{build_version}" + build = match.group("build") + flavor = match.group("flavor") + variant_parts: list[str] = [] + if build: + variant_parts.extend(build.split("+")) + if flavor: + variant_parts.append(flavor) + variant = "+".join(variant_parts) if variant_parts else "" + + url_prefix = f"https://github.com/{repo}/releases/download/{tag}/" + url = url_prefix + quote(filename, safe="") + archive_format = "tar.zst" if filename.endswith(".tar.zst") else "tar.gz" + + artifact = { + "platform": match.group("triple"), + "variant": variant, + "url": url, + "archive_format": archive_format, + "sha256": checksum, + } + versions[version].append(artifact) + + payload_versions: list[dict[str, object]] = [] + now = datetime.now(timezone.utc).isoformat() + for version, artifacts in sorted(versions.items(), reverse=True): + artifacts.sort( + key=lambda artifact: (artifact["platform"], artifact.get("variant", "")) + ) + payload_versions.append( + { + "version": version, + "date": now, + "artifacts": artifacts, + } + ) + + for version in payload_versions: + print(json.dumps(version, separators=(",", ":"))) + + +if __name__ == "__main__": + main()