66import json
77import shutil
88import subprocess
9+ import sys
910import tomllib
1011import urllib .error
1112import urllib .request
1516ROOT = Path (__file__ ).resolve ().parents [1 ]
1617DEFAULT_REPO = "bhack/mini-eq"
1718PYPI_JSON_URL = "https://pypi.org/pypi/mini-eq/json"
19+ PYPI_VERSION_JSON_URL = "https://pypi.org/pypi/mini-eq/{version}/json"
1820PYPI_VERSION_URL = "https://pypi.org/project/mini-eq/{version}/"
1921SDIST_NAME = "mini_eq-{version}.tar.gz"
2022WHEEL_NAME = "mini_eq-{version}-py3-none-any.whl"
@@ -70,7 +72,7 @@ def asset_by_name(release: dict[str, Any], name: str) -> dict[str, Any]:
7072 raise SystemExit (f"GitHub release is missing asset: { name } " )
7173
7274
73- def check_github_release (version : str , tag : str , repo : str ) -> str :
75+ def check_github_release (version : str , tag : str , repo : str ) -> dict [ str , str ] :
7476 release = gh_json (
7577 [
7678 "gh" ,
@@ -90,33 +92,60 @@ def check_github_release(version: str, tag: str, repo: str) -> str:
9092 raise SystemExit (f"GitHub release { tag } is still a draft" )
9193
9294 expected_names = (SDIST_NAME .format (version = version ), WHEEL_NAME .format (version = version ))
95+ asset_shas : dict [str , str ] = {}
9396 for name in expected_names :
9497 asset = asset_by_name (release , name )
9598 url = asset ["url" ]
9699 if f"/download/{ tag } /" not in url :
97100 raise SystemExit (f"GitHub release asset still has an unstable URL: { url } " )
101+ asset_sha = sha256_url (url )
102+ asset_shas [name ] = asset_sha
103+ expected_digest = asset .get ("digest" )
104+ if expected_digest and expected_digest != f"sha256:{ asset_sha } " :
105+ raise SystemExit (
106+ f"Downloaded GitHub release asset SHA-256 does not match the asset digest: "
107+ f"{ name } : { asset_sha } != { expected_digest } "
108+ )
98109
99110 tag_lookup = run (["git" , "ls-remote" , "--tags" , "origin" , tag ])
100111 if not tag_lookup .stdout .strip ():
101112 raise SystemExit (f"Remote tag not found on origin: { tag } " )
102113
103- sdist = asset_by_name (release , expected_names [0 ])
104- sdist_sha = sha256_url (sdist ["url" ])
105- expected_digest = sdist .get ("digest" )
106- if expected_digest and expected_digest != f"sha256:{ sdist_sha } " :
107- raise SystemExit (
108- f"Downloaded sdist SHA-256 does not match the GitHub release asset digest: { sdist_sha } != { expected_digest } "
109- )
110-
111114 print (f"GitHub release is published: { release ['url' ]} " )
112115 print (f"Remote tag exists: { tag_lookup .stdout .strip ()} " )
113- print (f"Flathub source archive SHA-256: { sdist_sha } " )
114- return sdist_sha
116+ print (f"Flathub source archive SHA-256: { asset_shas [ expected_names [ 0 ]] } " )
117+ return asset_shas
115118
116119
117- def check_pypi (version : str ) -> None :
118- pypi_json = json .loads (fetch_url (PYPI_JSON_URL ))
119- json_version = pypi_json ["info" ]["version" ]
120+ def check_pypi (version : str , github_shas : dict [str , str ], * , strict_artifact_match : bool ) -> None :
121+ version_json = json .loads (fetch_url (PYPI_VERSION_JSON_URL .format (version = version )))
122+ version_json_version = version_json ["info" ]["version" ]
123+ if version_json_version != version :
124+ raise SystemExit (f"PyPI version JSON mismatch: expected { version } , got { version_json_version } " )
125+
126+ pypi_files = {file ["filename" ]: file for file in version_json ["urls" ]}
127+ expected_names = (SDIST_NAME .format (version = version ), WHEEL_NAME .format (version = version ))
128+ for name in expected_names :
129+ if name not in pypi_files :
130+ raise SystemExit (f"PyPI is missing artifact: { name } " )
131+ pypi_sha = pypi_files [name ]["digests" ]["sha256" ]
132+ print (f"PyPI artifact: { name } sha256:{ pypi_sha } " )
133+
134+ github_sha = github_shas .get (name )
135+ if github_sha and github_sha != pypi_sha :
136+ message = (
137+ f"PyPI and GitHub release artifact SHA-256 differ for { name } : "
138+ f"pypi={ pypi_sha } github={ github_sha } . Publish both channels from the same release workflow run "
139+ "when artifact parity is required."
140+ )
141+ if strict_artifact_match :
142+ raise SystemExit (message )
143+ print (f"WARNING: { message } " , file = sys .stderr )
144+
145+ print (f"PyPI version JSON reports: { version_json_version } " )
146+
147+ project_json = json .loads (fetch_url (PYPI_JSON_URL ))
148+ json_version = project_json ["info" ]["version" ]
120149 version_url = PYPI_VERSION_URL .format (version = version )
121150
122151 if json_version == version :
@@ -142,6 +171,11 @@ def parse_args() -> argparse.Namespace:
142171 help = "release version to verify; defaults to pyproject.toml" ,
143172 )
144173 parser .add_argument ("--repo" , default = DEFAULT_REPO , help = f"GitHub repository; defaults to { DEFAULT_REPO } " )
174+ parser .add_argument (
175+ "--strict-artifact-match" ,
176+ action = "store_true" ,
177+ help = "fail when GitHub release asset SHA-256 values differ from PyPI artifact SHA-256 values" ,
178+ )
145179 return parser .parse_args ()
146180
147181
@@ -151,8 +185,8 @@ def main() -> int:
151185 tag = f"v{ version } "
152186
153187 require_tools ("gh" , "git" )
154- check_github_release (version , tag , args .repo )
155- check_pypi (version )
188+ github_shas = check_github_release (version , tag , args .repo )
189+ check_pypi (version , github_shas , strict_artifact_match = args . strict_artifact_match )
156190 print ("Post-publish checks passed." )
157191 return 0
158192
0 commit comments