Repository navigation
feat: add uv Python package publishing #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
ab4f98a
feat: add guarded python release workflow
imbajin db25969
fix: validate supported Python release versions
imbajin 61e02c4
fix: reject unexpected remote release artifacts
imbajin faf5592
docs: condense workflow usage and maintenance
imbajin d7b62c7
docs: focus release guide on operational choices
imbajin 67a6006
docs: restore release architecture overview
imbajin 82b42a4
docs: illustrate shared image publishing flow
imbajin 76cd18b
fix: verify release artifacts before and after tests
imbajin 81dfe90
feat: accept explicit four-part test versions
imbajin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,263 @@ | ||
| """Guards for the single HugeGraph Python release workflow (Python 3.11+).""" | ||
|
|
||
| import argparse | ||
| import email.parser | ||
| import hashlib | ||
| import json | ||
| import os | ||
| import re | ||
| import subprocess | ||
| import tarfile | ||
| import tomllib | ||
| import urllib.error | ||
| import urllib.parse | ||
| import urllib.request | ||
| import zipfile | ||
| from pathlib import Path | ||
|
|
||
| SOURCE = "apache/hugegraph-ai" | ||
| PACKAGE = "hugegraph-python" | ||
| MODULE = "hugegraph-python-client" | ||
| TARGETS = { | ||
| "testpypi": ("https://test.pypi.org/legacy/", "https://test.pypi.org"), | ||
| "pypi": ("https://upload.pypi.org/legacy/", "https://pypi.org"), | ||
| } | ||
|
|
||
|
|
||
| def require(condition, message): | ||
| if not condition: | ||
| raise ValueError(message) | ||
|
|
||
|
|
||
| def digest(path): | ||
| return hashlib.sha256(path.read_bytes()).hexdigest() | ||
|
|
||
|
|
||
| def output(**values): | ||
| with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream: | ||
| for key, value in values.items(): | ||
| require("\n" not in value and "\r" not in value, "Invalid output") | ||
| stream.write(f"{key}={value}\n") | ||
|
|
||
|
|
||
| def github(endpoint): | ||
| return json.loads( | ||
| subprocess.check_output(["gh", "api", f"repos/{SOURCE}/{endpoint}"]) | ||
| ) | ||
|
|
||
|
|
||
| def resolve(ref, target): | ||
| require(bool(ref) and not any(c in ref for c in "\n\r"), "Invalid source ref") | ||
| tag = ref.removeprefix("refs/tags/") | ||
| if target == "pypi": | ||
| obj = github("git/ref/tags/" + urllib.parse.quote(tag, safe=""))["object"] | ||
| while obj["type"] == "tag": | ||
| obj = github("git/tags/" + obj["sha"])["object"] | ||
| require(obj["type"] == "commit", "Tag must identify a commit") | ||
| sha = obj["sha"] | ||
| else: | ||
| sha = github("commits/" + urllib.parse.quote(ref, safe=""))["sha"] | ||
| tag = "" | ||
| require(re.fullmatch(r"[0-9a-f]{40}", sha), "Invalid source SHA") | ||
| output(source_sha=sha, tag=tag) | ||
|
|
||
|
|
||
| def metadata(source, target, tag, version): | ||
| project = tomllib.loads((source / MODULE / "pyproject.toml").read_text())["project"] | ||
| require(project["name"] == PACKAGE, f"Distribution name must be {PACKAGE}") | ||
| base = project["version"] | ||
| number = r"(?:0|[1-9][0-9]*)" | ||
| require( | ||
| re.fullmatch(rf"{number}\.{number}\.{number}", base), | ||
| "Source version must be x.y.z", | ||
| ) | ||
| if target == "pypi": | ||
| require(not version, "test_version must be empty for PyPI") | ||
| require(tag == base, "Tag/version mismatch") | ||
| version = base | ||
| else: | ||
| require( | ||
| re.fullmatch(rf"{number}\.{number}\.{number}\.{number}", version), | ||
| "TestPyPI requires test_version in x.y.z.n format", | ||
| ) | ||
| require( | ||
| version.rsplit(".", 1)[0] == base, | ||
| "Test version must extend the source version", | ||
| ) | ||
| subprocess.run( | ||
| [ | ||
| "uv", | ||
| "version", | ||
| "--project", | ||
| str(source / MODULE), | ||
| "--frozen", | ||
| version, | ||
| ], | ||
| check=True, | ||
| ) | ||
| output(version=version) | ||
|
|
||
|
|
||
| def artifact_metadata(path): | ||
| if path.name.endswith(".whl"): | ||
| with zipfile.ZipFile(path) as archive: | ||
| names = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")] | ||
| require(len(names) == 1, "Wheel must contain exactly one METADATA") | ||
| raw = archive.read(names[0]) | ||
| else: | ||
| with tarfile.open(path, "r:gz") as archive: | ||
| members = [ | ||
| m | ||
| for m in archive.getmembers() | ||
| if m.name.count("/") == 1 and m.name.endswith("/PKG-INFO") | ||
| ] | ||
| require( | ||
| len(members) == 1 and members[0].isfile(), | ||
| "Sdist must contain one root PKG-INFO", | ||
| ) | ||
| raw = archive.extractfile(members[0]).read() | ||
| msg = email.parser.BytesParser().parsebytes(raw) | ||
| require( | ||
| len(msg.get_all("Name", [])) == 1 and len(msg.get_all("Version", [])) == 1, | ||
| "Ambiguous metadata", | ||
| ) | ||
| return msg["Name"], msg["Version"] | ||
|
|
||
|
|
||
| def inventory(dist, version): | ||
| # uv creates this hidden cache marker; upload-artifact omits hidden files. | ||
| files = sorted( | ||
| p for p in dist.iterdir() if p.name not in ("manifest.json", ".gitignore") | ||
| ) | ||
| require(len(files) == 2, "Expected exactly one wheel and one sdist") | ||
| require(sum(p.name.endswith(".whl") for p in files) == 1, "Expected one wheel") | ||
| require(sum(p.name.endswith(".tar.gz") for p in files) == 1, "Expected one sdist") | ||
| result = {} | ||
| for path in files: | ||
| require(path.is_file() and not path.is_symlink(), "Expected regular artifact") | ||
| require( | ||
| re.fullmatch( | ||
| r"hugegraph_python-[A-Za-z0-9_.+!-]+\.(whl|tar\.gz)", path.name | ||
| ), | ||
| "Unexpected filename", | ||
| ) | ||
| require( | ||
| artifact_metadata(path) == (PACKAGE, version), "Artifact metadata mismatch" | ||
| ) | ||
| result[path.name] = digest(path) | ||
| return result | ||
|
|
||
|
|
||
| def manifest(dist, sha, version): | ||
| require(re.fullmatch(r"[0-9a-f]{40}", sha), "Invalid source SHA") | ||
| data = { | ||
| "source": SOURCE, | ||
| "source_sha": sha, | ||
| "name": PACKAGE, | ||
| "version": version, | ||
| "files": inventory(dist, version), | ||
| } | ||
| path = dist / "manifest.json" | ||
| path.write_text(json.dumps(data, sort_keys=True, indent=2) + "\n") | ||
| output(manifest_sha=digest(path)) | ||
|
|
||
|
|
||
| def verify(dist, sha, version, manifest_sha): | ||
| path = dist / "manifest.json" | ||
| require(path.is_file() and not path.is_symlink(), "Missing regular manifest") | ||
| require(digest(path) == manifest_sha, "Manifest hash mismatch") | ||
| data = json.loads(path.read_text()) | ||
| require( | ||
| data | ||
| == { | ||
| "source": SOURCE, | ||
| "source_sha": sha, | ||
| "name": PACKAGE, | ||
| "version": version, | ||
| "files": inventory(dist, version), | ||
| }, | ||
| "Manifest contents mismatch", | ||
| ) | ||
| return data["files"] | ||
|
|
||
|
|
||
| def remote_files(target, version): | ||
| url = f"{TARGETS[target][1]}/pypi/{PACKAGE}/{urllib.parse.quote(version, safe='')}/json" | ||
| try: | ||
| with urllib.request.urlopen(url, timeout=30) as response: | ||
| data = json.load(response) | ||
| except urllib.error.HTTPError as error: | ||
| if error.code == 404: | ||
| return {} | ||
| raise | ||
| result = {} | ||
| for item in data["urls"]: | ||
| name, sha = item["filename"], item["digests"]["sha256"] | ||
| require(re.fullmatch(r"[0-9a-f]{64}", sha), "Missing/invalid remote SHA-256") | ||
| require(name not in result, "Duplicate remote filename") | ||
| result[name] = sha | ||
| return result | ||
|
|
||
|
|
||
| def publish(dist, sha, version, manifest_sha, target): | ||
| files = verify(dist, sha, version, manifest_sha) | ||
| remote = remote_files(target, version) | ||
| # Finish the entire preflight before starting uv, including partial retries. | ||
| unexpected = sorted(remote.keys() - files.keys()) | ||
| require(not unexpected, f"Unexpected remote artifacts: {', '.join(unexpected)}") | ||
| for name, checksum in files.items(): | ||
| require( | ||
| name not in remote or remote[name] == checksum, | ||
| f"Remote hash conflict: {name}", | ||
| ) | ||
| pending = [str((dist / name).resolve()) for name in files if name not in remote] | ||
| if not pending: | ||
| print("All artifacts already exist with identical SHA-256; nothing to upload.") | ||
| return | ||
| require(bool(os.environ.get("UV_PUBLISH_TOKEN")), "Missing environment token") | ||
| subprocess.run( | ||
| [ | ||
| "uv", | ||
| "publish", | ||
| "--no-config", | ||
| "--trusted-publishing", | ||
| "never", | ||
| "--publish-url", | ||
| TARGETS[target][0], | ||
| "--check-url", | ||
| TARGETS[target][1] + "/simple/", | ||
| *pending, | ||
| ], | ||
| check=True, | ||
| ) | ||
|
|
||
|
|
||
| def main(): | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument( | ||
| "command", choices=("resolve", "metadata", "manifest", "verify", "publish") | ||
| ) | ||
| parser.add_argument("--target", choices=TARGETS, default="testpypi") | ||
| parser.add_argument("--source-ref", default="main") | ||
| parser.add_argument("--source", type=Path, default=Path("source")) | ||
| parser.add_argument("--tag", default="") | ||
| parser.add_argument("--dist", type=Path, default=Path("dist")) | ||
| parser.add_argument("--sha", default="") | ||
| parser.add_argument("--version", default="") | ||
| parser.add_argument("--test-version", default="") | ||
| parser.add_argument("--manifest-sha", default="") | ||
| args = parser.parse_args() | ||
| if args.command == "resolve": | ||
| resolve(args.source_ref, args.target) | ||
| elif args.command == "metadata": | ||
| metadata(args.source, args.target, args.tag, args.test_version) | ||
| elif args.command == "manifest": | ||
| manifest(args.dist, args.sha, args.version) | ||
| elif args.command == "verify": | ||
| verify(args.dist, args.sha, args.version, args.manifest_sha) | ||
| else: | ||
| publish(args.dist, args.sha, args.version, args.manifest_sha, args.target) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.