Skip to content

Commit e219cfb

Browse files
claudeluhenry
authored andcommitted
Suffix release tags with run_id and add versions-manifest.json pipeline
- build-python.yml: tag each release as <normalised>-<GITHUB_RUN_ID> so rebuilds don't clobber older artefacts; title/notes stay at the plain normalised version. - check-releases.yml: fetch existing release tags once and dedupe against the regex ^<normalised>-[0-9]+$ so 3.12.0 and 3.12.0rc3 don't collide. - update-manifest.yml + scripts/update-manifest.py: on release published/deleted (and workflow_dispatch), regenerate versions-manifest.json from the authoritative release list and force-push to a rolling auto/update-manifest branch as a single PR against main. Matches the actions/python-versions manifest schema. - README: document the new tag format and manifest workflow.
1 parent 4938f6c commit e219cfb

5 files changed

Lines changed: 260 additions & 19 deletions

File tree

.github/scripts/update-manifest.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env python3
2+
"""Regenerate versions-manifest.json from the repository's GitHub releases.
3+
4+
Reads `GH_TOKEN` and `GITHUB_REPOSITORY` from the environment and paginates
5+
through every release on the repo. Each release is expected to have a tag of
6+
the form ``<normalised-version>-<run_id>`` (e.g. ``3.15.0-alpha.7-22913288817``).
7+
For each distinct version the release with the largest ``run_id`` wins, and an
8+
entry is emitted in the manifest with its assets.
9+
10+
Output matches the schema used by actions/python-versions.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import os
17+
import re
18+
import sys
19+
import urllib.error
20+
import urllib.parse
21+
import urllib.request
22+
23+
API_ROOT = "https://api.github.com"
24+
TAG_RE = re.compile(r"^(?P<version>.+)-(?P<run_id>\d+)$")
25+
PRERELEASE_RE = re.compile(r"-(alpha|beta|rc)\.(\d+)$")
26+
FILENAME_PLATFORM_RE = re.compile(
27+
r"-linux-(?P<platform_version>\d+\.\d+)-(?P<arch>[^.]+?)(?P<ft>-freethreaded)?\.tar\.gz$"
28+
)
29+
30+
31+
def gh_get(path: str, token: str) -> list[dict]:
32+
"""Paginate a GitHub REST API list endpoint."""
33+
results: list[dict] = []
34+
url: str | None = f"{API_ROOT}{path}"
35+
if "?" in url:
36+
url += "&per_page=100"
37+
else:
38+
url += "?per_page=100"
39+
while url:
40+
req = urllib.request.Request(
41+
url,
42+
headers={
43+
"Authorization": f"Bearer {token}",
44+
"Accept": "application/vnd.github+json",
45+
"X-GitHub-Api-Version": "2022-11-28",
46+
"User-Agent": "update-manifest-script",
47+
},
48+
)
49+
with urllib.request.urlopen(req) as resp:
50+
payload = json.load(resp)
51+
results.extend(payload)
52+
link = resp.headers.get("Link", "")
53+
url = _next_link(link)
54+
return results
55+
56+
57+
def _next_link(link_header: str) -> str | None:
58+
for part in link_header.split(","):
59+
section = part.strip()
60+
if section.endswith('rel="next"'):
61+
return section.split(";", 1)[0].strip().lstrip("<").rstrip(">")
62+
return None
63+
64+
65+
def version_sort_key(version: str) -> tuple[int, int, int, int, int]:
66+
"""Return a tuple suitable for descending version sort.
67+
68+
Order: (major, minor, patch, stage_order, stage_num)
69+
where stage_order is 0=alpha, 1=beta, 2=rc, 3=final.
70+
"""
71+
match = PRERELEASE_RE.search(version)
72+
if match:
73+
stage_map = {"alpha": 0, "beta": 1, "rc": 2}
74+
stage_order = stage_map[match.group(1)]
75+
stage_num = int(match.group(2))
76+
base = version[: match.start()]
77+
else:
78+
stage_order = 3
79+
stage_num = 0
80+
base = version
81+
parts = base.split(".")
82+
major = int(parts[0])
83+
minor = int(parts[1]) if len(parts) > 1 else 0
84+
patch = int(parts[2]) if len(parts) > 2 else 0
85+
return (major, minor, patch, stage_order, stage_num)
86+
87+
88+
def is_stable(version: str) -> bool:
89+
return PRERELEASE_RE.search(version) is None
90+
91+
92+
def build_file_entry(asset: dict) -> dict | None:
93+
filename = asset["name"]
94+
match = FILENAME_PLATFORM_RE.search(filename)
95+
if not match:
96+
return None
97+
arch = match.group("arch")
98+
if match.group("ft"):
99+
arch = f"{arch}-freethreaded"
100+
return {
101+
"filename": filename,
102+
"arch": arch,
103+
"platform": "linux",
104+
"platform_version": match.group("platform_version"),
105+
"download_url": asset["browser_download_url"],
106+
}
107+
108+
109+
def main() -> int:
110+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
111+
repo = os.environ.get("GITHUB_REPOSITORY")
112+
if not token or not repo:
113+
print("GH_TOKEN and GITHUB_REPOSITORY must be set", file=sys.stderr)
114+
return 1
115+
116+
releases = gh_get(f"/repos/{repo}/releases", token)
117+
118+
# Pick the release with the highest run_id per version.
119+
best: dict[str, dict] = {}
120+
for release in releases:
121+
if release.get("draft"):
122+
continue
123+
tag = release.get("tag_name") or ""
124+
match = TAG_RE.match(tag)
125+
if not match:
126+
continue
127+
version = match.group("version")
128+
run_id = int(match.group("run_id"))
129+
existing = best.get(version)
130+
if existing is None or run_id > existing["_run_id"]:
131+
best[version] = {"_run_id": run_id, "release": release}
132+
133+
entries: list[dict] = []
134+
for version, picked in best.items():
135+
release = picked["release"]
136+
tag = release["tag_name"]
137+
files = []
138+
for asset in release.get("assets") or []:
139+
entry = build_file_entry(asset)
140+
if entry is not None:
141+
files.append(entry)
142+
entries.append(
143+
{
144+
"version": version,
145+
"stable": is_stable(version),
146+
"release_url": f"https://github.com/{repo}/releases/tag/{tag}",
147+
"files": files,
148+
}
149+
)
150+
151+
entries.sort(key=lambda e: version_sort_key(e["version"]), reverse=True)
152+
153+
out = json.dumps(entries, indent=2)
154+
with open("versions-manifest.json", "w", encoding="utf-8") as f:
155+
f.write(out)
156+
print(f"Wrote versions-manifest.json with {len(entries)} entries", file=sys.stderr)
157+
return 0
158+
159+
160+
if __name__ == "__main__":
161+
sys.exit(main())

.github/workflows/build-python.yml

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ permissions:
1818

1919
defaults:
2020
run:
21-
shell: bash --noprofile --norc -exo pipefail {0}
21+
shell: bash --noprofile --norc -euxo pipefail {0}
2222

2323
jobs:
2424
build:
@@ -65,7 +65,6 @@ jobs:
6565
- name: Compute normalised version
6666
id: vars
6767
run: |
68-
set -euo pipefail
6968
tag="$CPYTHON_TAG"
7069
# Strip leading v, expand a/b/rc suffixes
7170
normalised="${tag#v}"
@@ -93,7 +92,6 @@ jobs:
9392
ARCHIVE_BASE: ${{ steps.vars.outputs.archive_base }}
9493
NORMALISED: ${{ steps.vars.outputs.normalised }}
9594
run: |
96-
set -euo pipefail
9795
mkdir -p output
9896
pushd src
9997
./configure --with-ensurepip=install --enable-shared
@@ -127,7 +125,6 @@ jobs:
127125
ARCHIVE_BASE: ${{ steps.vars.outputs.archive_base }}
128126
NORMALISED: ${{ steps.vars.outputs.normalised }}
129127
run: |
130-
set -euo pipefail
131128
ft_base="${ARCHIVE_BASE}-freethreaded"
132129
pushd src
133130
git clean -fdx
@@ -162,12 +159,8 @@ jobs:
162159
env:
163160
NORMALISED: ${{ steps.vars.outputs.normalised }}
164161
run: |
165-
set -euo pipefail
166-
if gh release view "$NORMALISED" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
167-
gh release upload "$NORMALISED" --repo "$GITHUB_REPOSITORY" --clobber ./output/*.tar.gz
168-
else
169-
gh release create "$NORMALISED" --repo "$GITHUB_REPOSITORY" \
170-
--title "$NORMALISED" \
171-
--notes "Python $NORMALISED" \
172-
./output/*.tar.gz
173-
fi
162+
TAG="${NORMALISED}-${GITHUB_RUN_ID}"
163+
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" \
164+
--title "$NORMALISED" \
165+
--notes "Python $NORMALISED" \
166+
./output/*.tar.gz

.github/workflows/check-releases.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232
# Fetch all v* tags from python/cpython, ordered by version, descending (newer first)
3333
mapfile -t refs < <(gh api --paginate repos/python/cpython/git/matching-refs/tags/v -q '.[].ref' | sort -V --reverse)
3434
35+
# Fetch all existing release tags in this repo once
36+
mapfile -t existing_tags < <(gh api --paginate "repos/$GITHUB_REPOSITORY/releases" -q '.[].tag_name')
37+
3538
for ref in "${refs[@]}"; do
3639
tag="${ref#refs/tags/}"
3740
# Filter: 3.10+, with optional a/b/rc suffix, no dev/post
@@ -46,7 +49,15 @@ jobs:
4649
4750
echo "Tag $tag -> release $normalised"
4851
49-
if gh release view "$normalised" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
52+
normalised_re="${normalised//./\\.}"
53+
already=0
54+
for t in "${existing_tags[@]}"; do
55+
if [[ "$t" =~ ^${normalised_re}-[0-9]+$ ]]; then
56+
already=1
57+
break
58+
fi
59+
done
60+
if [ "$already" = 1 ]; then
5061
echo " already released, skipping"
5162
continue
5263
fi
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Update versions-manifest.json
2+
3+
on:
4+
release:
5+
types: [published, deleted]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
defaults:
13+
run:
14+
shell: bash --noprofile --norc -euxo pipefail {0}
15+
16+
jobs:
17+
update:
18+
runs-on: ubuntu-latest
19+
env:
20+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21+
GITHUB_REPOSITORY: ${{ github.repository }}
22+
PR_BRANCH: auto/update-manifest
23+
steps:
24+
- name: Checkout main
25+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
26+
with:
27+
ref: main
28+
fetch-depth: 0
29+
30+
- name: Set up Python
31+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
32+
with:
33+
python-version: '3.12'
34+
35+
- name: Regenerate manifest
36+
run: python .github/scripts/update-manifest.py
37+
38+
- name: Commit and push (if changed)
39+
run: |
40+
if git diff --quiet -- versions-manifest.json; then
41+
echo "No manifest changes"
42+
exit 0
43+
fi
44+
45+
git config user.name 'github-actions[bot]'
46+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
47+
git switch -C "$PR_BRANCH"
48+
git add versions-manifest.json
49+
git commit -m "Update versions-manifest.json"
50+
git push --force origin "$PR_BRANCH"
51+
52+
if gh pr list --repo "$GITHUB_REPOSITORY" --head "$PR_BRANCH" --state open --json number -q '.[].number' | grep -q .; then
53+
echo "Existing PR updated via force-push"
54+
else
55+
gh pr create --repo "$GITHUB_REPOSITORY" \
56+
--base main \
57+
--head "$PR_BRANCH" \
58+
--title "Update versions-manifest.json" \
59+
--body "Automated refresh of \`versions-manifest.json\` from the current list of releases."
60+
fi

README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ additional assets on the same release for CPython 3.13+.
1515

1616
## Release / asset naming
1717

18-
| CPython tag | Release tag | Asset |
19-
| ------------ | ---------------- | ------------------------------------------------------------------ |
20-
| `v3.12.13` | `3.12.13` | `python-3.12.13-linux-24.04-riscv64.tar.gz` |
21-
| `v3.13.3` | `3.13.3` | `python-3.13.3-linux-24.04-riscv64.tar.gz` (+ `-freethreaded`) |
22-
| `v3.15.0a7` | `3.15.0-alpha.7` | `python-3.15.0-alpha.7-linux-24.04-riscv64.tar.gz` (+ `-freethreaded`) |
18+
Release **tags** include the GitHub Actions `run_id` of the build so rebuilds
19+
don't clobber older artefacts. Release **titles** and **notes** stay at the
20+
plain normalised version.
21+
22+
| CPython tag | Release title | Release tag | Asset |
23+
| ------------ | ---------------- | -------------------------------- | --------------------------------------------------------------------- |
24+
| `v3.12.13` | `3.12.13` | `3.12.13-<run_id>` | `python-3.12.13-linux-24.04-riscv64.tar.gz` |
25+
| `v3.13.3` | `3.13.3` | `3.13.3-<run_id>` | `python-3.13.3-linux-24.04-riscv64.tar.gz` (+ `-freethreaded`) |
26+
| `v3.15.0a7` | `3.15.0-alpha.7` | `3.15.0-alpha.7-<run_id>` | `python-3.15.0-alpha.7-linux-24.04-riscv64.tar.gz` (+ `-freethreaded`) |
2327

2428
## Using a release
2529

@@ -35,6 +39,18 @@ cd python-3.12.13-linux-24.04-riscv64
3539
./setup.sh
3640
```
3741

42+
## Versions manifest
43+
44+
`versions-manifest.json` at the repo root lists every published release in the
45+
same schema as
46+
[`actions/python-versions`](https://github.com/actions/python-versions/blob/main/versions-manifest.json),
47+
mapping each `version` to its `release_url` and per-file `download_url`s.
48+
49+
The `Update versions-manifest.json` workflow regenerates it from the
50+
authoritative release list whenever a release is published or deleted (and on
51+
manual dispatch). Changes are force-pushed to the `auto/update-manifest` branch
52+
as a single rolling PR against `main`.
53+
3854
## Manual dispatch
3955

4056
- Build one tag: trigger the **Build Python** workflow with `cpython_tag=v3.12.13` (optionally `freethreaded=true` for 3.13+).

0 commit comments

Comments
 (0)