Skip to content

Commit fd5e1cc

Browse files
authored
python-wheels: support automatic documentation updates (#169)
* ci_scripts: update_doc.py: add Add a new version of the update_doc.py script from wheel_builder, converted using Claude. Compared to the old version, we update documentation in docs/packages/<package>.md instead of the longer docs/source/packages/<package>.yaml. * build-numpy.yml: add doc update step * actions: publish-wheels: add composite publishing action * workflows: build-numpy.yml: use publish-wheels * docs: development.md: update publish example * workflows: build-numpy: trigger only on changes to workflow Don't trigger the build-numpy.yml workflow if a change has been made to other files, such as doc update scripts or documentation itself. --------- AI-Generated: Uses Claude Code Sonnet 5 Signed-off-by: Trevor Gamblin <tgamblin@baylibre.com>
1 parent 0e47bdd commit fd5e1cc

4 files changed

Lines changed: 383 additions & 31 deletions

File tree

.github/workflows/build-numpy.yml

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ on:
1111
pull_request:
1212
paths:
1313
- '.github/workflows/build-numpy.yml'
14-
- 'actions/publish-to-gitlab/**'
1514

1615
concurrency:
1716
group: ${{ github.workflow }}-${{ inputs.version || '2.5.1' }}-${{ github.head_ref || github.run_id }}
@@ -103,21 +102,15 @@ jobs:
103102
if: github.ref == 'refs/heads/main'
104103
runs-on: ubuntu-latest
105104
permissions:
106-
contents: read
105+
contents: write
106+
pull-requests: write
107107

108108
steps:
109-
- name: Download wheels
110-
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
111-
with:
112-
pattern: numpy-${{ env.NUMPY_VERSION }}-*-manylinux_riscv64
113-
path: dist
114-
merge-multiple: true
115-
116-
- name: Publish to GitLab PyPI registry
117-
uses: riseproject-dev/python-wheels/actions/publish-to-gitlab@main
109+
- name: Publish wheels and open docs PR
110+
uses: riseproject-dev/python-wheels/actions/publish-wheels@main
118111
with:
112+
artifact-pattern: numpy-${{ env.NUMPY_VERSION }}-*-manylinux_riscv64
119113
gitlab-username: ${{ vars.GITLAB_DEPLOY_USER }}
120114
gitlab-token: ${{ secrets.GITLAB_DEPLOY_TOKEN }}
121115
gitlab-project-id: ${{ vars.GITLAB_PROJECT_ID }}
122-
files: |
123-
dist/*.whl
116+
gh-token: ${{ secrets.GITHUB_TOKEN }}

actions/publish-wheels/action.yml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: 'Publish riscv64 Wheels and Document Release'
2+
description: >
3+
Checks out python-wheels, downloads a package's built wheel artifacts,
4+
publishes them to the GitLab PyPI Package Registry, and opens a pull
5+
request documenting the new version in docs/packages/. Wraps the publish
6+
job body shared by every build-<package>.yml workflow so it doesn't need
7+
to be duplicated per package.
8+
9+
inputs:
10+
11+
# ── Required ────────────────────────────────────────────────────────────────
12+
13+
artifact-pattern:
14+
description: >
15+
Pattern passed to actions/download-artifact to select this package's
16+
wheel artifacts, e.g. "numpy-2.5.1-*-manylinux_riscv64".
17+
required: true
18+
19+
gitlab-username:
20+
description: Passed through to the publish-to-gitlab action.
21+
required: true
22+
23+
gitlab-token:
24+
description: Passed through to the publish-to-gitlab action.
25+
required: true
26+
27+
gitlab-project-id:
28+
description: Passed through to the publish-to-gitlab action.
29+
required: true
30+
31+
gh-token:
32+
description: >
33+
GitHub token used to push the docs branch and open the docs PR.
34+
Composite actions cannot read the `secrets` context directly, so the
35+
caller must pass it explicitly (e.g. secrets.GITHUB_TOKEN).
36+
required: true
37+
38+
# ── Optional ────────────────────────────────────────────────────────────────
39+
40+
artifact-path:
41+
description: Directory the wheel artifacts are downloaded into.
42+
required: false
43+
default: 'dist'
44+
45+
files:
46+
description: Newline-separated glob(s) of files to publish. Passed through to publish-to-gitlab.
47+
required: false
48+
default: 'dist/*.whl'
49+
50+
skip-existing:
51+
description: Passed through to the publish-to-gitlab action.
52+
required: false
53+
default: 'false'
54+
55+
twine-version:
56+
description: Passed through to the publish-to-gitlab action.
57+
required: false
58+
default: ''
59+
60+
runs:
61+
using: 'composite'
62+
steps:
63+
64+
- name: Checkout python-wheels
65+
uses: actions/checkout@v4
66+
with:
67+
fetch-depth: 0
68+
69+
- name: Download wheels
70+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
71+
with:
72+
pattern: ${{ inputs.artifact-pattern }}
73+
path: ${{ inputs.artifact-path }}
74+
merge-multiple: true
75+
76+
- name: Publish to GitLab PyPI registry
77+
uses: riseproject-dev/python-wheels/actions/publish-to-gitlab@main
78+
with:
79+
gitlab-username: ${{ inputs.gitlab-username }}
80+
gitlab-token: ${{ inputs.gitlab-token }}
81+
gitlab-project-id: ${{ inputs.gitlab-project-id }}
82+
files: ${{ inputs.files }}
83+
skip-existing: ${{ inputs.skip-existing }}
84+
twine-version: ${{ inputs.twine-version }}
85+
86+
- uses: actions/setup-python@v5
87+
with:
88+
python-version: '3'
89+
90+
- name: Install ci_scripts/update_doc.py dependencies
91+
shell: bash
92+
run: pip install pyyaml
93+
94+
- name: Open docs update PR
95+
shell: bash
96+
env:
97+
GH_TOKEN: ${{ inputs.gh-token }}
98+
ARTIFACTS_PATH: ${{ inputs.artifact-path }}
99+
run: python3 ci_scripts/update_doc.py

ci_scripts/update_doc.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: 2025 BayLibre, SAS
3+
# SPDX-FileCopyrightText: 2026 The RISE Project
4+
# SPDX-License-Identifier: MIT
5+
"""
6+
Extract metadata from a just-built riscv64 wheel, add or update the
7+
corresponding docs/packages/<name>.yaml entry with the new version, and open
8+
a pull request with the change.
9+
10+
docs/packages/generate_packages_doc.py renders this YAML into the published
11+
Markdown pages, so this script only needs to maintain the YAML source of
12+
truth; it never touches docs/packages/*.md or index.md directly.
13+
"""
14+
15+
import os
16+
import re
17+
import string
18+
import subprocess
19+
import sys
20+
import zipfile
21+
from email.message import Message
22+
from email.parser import Parser
23+
from pathlib import Path
24+
25+
import yaml
26+
27+
REPO = "riseproject-dev/python-wheels"
28+
DOCS_DIR = Path("docs/packages")
29+
PACKAGES_FILE = Path("ci_scripts/packages.txt")
30+
ARTIFACTS_PATH = os.environ.get("ARTIFACTS_PATH", "dist")
31+
32+
33+
def find_wheel_file(path):
34+
for file in Path(path).glob("*.whl"):
35+
return file
36+
return None
37+
38+
39+
def normalize_name(name):
40+
"""
41+
https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization
42+
"""
43+
return re.sub(r"[-_.]+", "-", name).lower()
44+
45+
46+
def normalize_label(label):
47+
"""
48+
https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization
49+
"""
50+
chars_to_remove = string.punctuation + string.whitespace
51+
removal_map = str.maketrans("", "", chars_to_remove)
52+
return label.translate(removal_map).lower()
53+
54+
55+
def extract_license(message):
56+
license = message.get("License-Expression")
57+
if not license:
58+
license = message.get("License", "Unknown")
59+
return license
60+
61+
62+
def extract_source_code_url(message):
63+
# Collect all "Project-URL" lines
64+
project_urls = message.get_all("Project-URL", [])
65+
well_known_labels = ["source", "repository", "sourcecode", "github"]
66+
67+
for entry in project_urls:
68+
try:
69+
label, url = map(str.strip, entry.split(",", 1))
70+
if normalize_label(label) in well_known_labels:
71+
return url
72+
except ValueError:
73+
continue # skip malformed lines
74+
75+
# A lot of projects use homepage as source code url. Done in a second
76+
# loop so a homepage entry appearing before a well-known source label
77+
# doesn't win by accident.
78+
for entry in project_urls:
79+
try:
80+
label, url = map(str.strip, entry.split(",", 1))
81+
if normalize_label(label) == "homepage":
82+
return url
83+
except ValueError:
84+
continue
85+
86+
return message.get("Home-page") # deprecated fallback, may be None
87+
88+
89+
def extract_metadata_from_whl(whl_path):
90+
"""
91+
Extract metadata according to https://packaging.python.org/en/latest/specifications/core-metadata/
92+
"""
93+
with zipfile.ZipFile(whl_path, "r") as z:
94+
metadata_file = next(f for f in z.namelist() if f.endswith("METADATA"))
95+
content = z.read(metadata_file).decode()
96+
message: Message = Parser().parsestr(content)
97+
return {
98+
"name": message.get("Name"),
99+
"version": message.get("Version"),
100+
"license": extract_license(message),
101+
"source_code": extract_source_code_url(message),
102+
}
103+
104+
105+
def find_patch_dir(slug, version):
106+
"""
107+
Look for a `patches/<slug>/<version_tag>` directory as described in
108+
docs/development.md, trying both a `v`-prefixed and bare version tag.
109+
"""
110+
for tag in (f"v{version}", version):
111+
candidate = Path("patches") / slug / tag
112+
if candidate.exists():
113+
return candidate
114+
return None
115+
116+
117+
def yaml_line(key, value):
118+
"""Render a single `key: value` YAML mapping line, quoted as needed."""
119+
return yaml.safe_dump(
120+
{key: value}, default_flow_style=False, allow_unicode=True
121+
).rstrip("\n")
122+
123+
124+
def render_new_yaml(slug, source_code, license, version, patch_dir):
125+
"""Render a brand-new docs/packages/<slug>.yaml for a package's first version."""
126+
lines = [yaml_line("package-name", slug)]
127+
if source_code:
128+
lines.append(yaml_line("source-code", source_code))
129+
lines.append(yaml_line("license", license))
130+
lines.append("versions:")
131+
lines.append(f" - {yaml_line('version', version)}")
132+
if patch_dir is not None:
133+
lines.append(" patched:")
134+
return "\n".join(lines) + "\n"
135+
136+
137+
def append_version(content, package_data, version, license, patch_dir):
138+
"""
139+
Append a new version entry to the end of an existing package YAML file's
140+
`versions:` list, preserving the rest of the file byte-for-byte.
141+
142+
Returns None if this exact version is already documented.
143+
"""
144+
existing_versions = {
145+
str(v.get("version")) for v in (package_data.get("versions") or [])
146+
}
147+
if str(version) in existing_versions:
148+
return None
149+
150+
top_level_license = package_data.get("license")
151+
lines = [f" - {yaml_line('version', version)}"]
152+
if patch_dir is not None:
153+
lines.append(" patched:")
154+
if license and license != top_level_license:
155+
lines.append(f" {yaml_line('license', license)}")
156+
157+
return content.rstrip("\n") + "\n" + "\n".join(lines) + "\n"
158+
159+
160+
def add_to_packages_file(slug):
161+
lines = PACKAGES_FILE.read_text().splitlines()
162+
header_end = next(i for i, line in enumerate(lines) if line and not line.startswith("#"))
163+
header, entries = lines[:header_end], [line for line in lines[header_end:] if line]
164+
entries = sorted(set(entries) | {slug}, key=str.casefold)
165+
PACKAGES_FILE.write_text("\n".join(header + entries) + "\n")
166+
167+
168+
def git_run(*args):
169+
subprocess.run(["git", *args], check=True)
170+
171+
172+
def configure_git_identity():
173+
git_run("config", "user.name", "github-actions[bot]")
174+
git_run("config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com")
175+
176+
177+
def extract_pr_url(stdout):
178+
for line in stdout.split("\n"):
179+
line = line.strip()
180+
if "github.com" in line and "/pull/" in line:
181+
return line
182+
return None
183+
184+
185+
def main():
186+
whl_file = find_wheel_file(ARTIFACTS_PATH)
187+
if not whl_file:
188+
print(f"No .whl file found in {ARTIFACTS_PATH}")
189+
sys.exit(1)
190+
191+
metadata = extract_metadata_from_whl(whl_file)
192+
display_name = metadata["name"]
193+
version = metadata["version"]
194+
license = metadata["license"]
195+
source_code = metadata["source_code"]
196+
197+
if not display_name or not version:
198+
print("Name or version could not be extracted")
199+
sys.exit(1)
200+
201+
slug = normalize_name(display_name)
202+
patch_dir = find_patch_dir(slug, version)
203+
yaml_path = DOCS_DIR / f"{slug}.yaml"
204+
is_new = not yaml_path.exists()
205+
206+
if is_new:
207+
yaml_path.write_text(
208+
render_new_yaml(slug, source_code, license, version, patch_dir)
209+
)
210+
else:
211+
content = yaml_path.read_text()
212+
package_data = yaml.safe_load(content) or {}
213+
updated = append_version(content, package_data, version, license, patch_dir)
214+
if updated is None:
215+
print(f"{slug} {version} is already documented; nothing to do")
216+
return
217+
yaml_path.write_text(updated)
218+
219+
configure_git_identity()
220+
221+
branch = f"github-actions/{'add' if is_new else 'update'}-doc-for-{slug}"
222+
git_run("switch", "-c", branch)
223+
git_run("add", str(yaml_path))
224+
225+
if is_new:
226+
add_to_packages_file(slug)
227+
git_run("add", str(PACKAGES_FILE))
228+
git_run("commit", "-s", "-m", f"docs: add {slug}\n\nAdd version {version}")
229+
else:
230+
git_run("commit", "-s", "-m", f"docs: update {slug}\n\nAdd version {version}")
231+
232+
git_run("push", "origin", branch)
233+
234+
result = subprocess.run(
235+
[
236+
"gh", "pr", "create", "--draft",
237+
"--repo", REPO,
238+
"--base", "main",
239+
"--head", branch,
240+
"--reviewer", "threexc,justeph",
241+
"--title", f"docs: {'add' if is_new else 'update'} {slug}",
242+
"--body",
243+
"Automatically generated PR to document a newly published wheel. "
244+
"Please review it carefully before merging.\n\n"
245+
"If necessary, force-push this branch.",
246+
],
247+
capture_output=True, text=True, check=True,
248+
)
249+
pr_url = extract_pr_url(result.stdout)
250+
print(f"[+] Opened PR: {pr_url or '(URL not found in output)'}")
251+
252+
253+
if __name__ == "__main__":
254+
main()

0 commit comments

Comments
 (0)