fixed testpypi upload

This commit is contained in:
ykcheungab
2026-09-12 17:34:46 +08:00
parent 94f38bc389
commit 488c8b0bc1
7 changed files with 1173 additions and 68 deletions
+99 -39
View File
@@ -32,16 +32,11 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from release import auth # noqa: E402
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
INDEX_JSON = {
"testpypi": "https://test.pypi.org/pypi/orisys/json",
"pypi": "https://pypi.org/pypi/orisys/json",
}
INDEX_SIMPLE = {
"testpypi": "https://test.pypi.org/simple/",
"pypi": "https://pypi.org/simple/",
}
INDEX_JSON = {name: str(meta["json_url"]) for name, meta in auth.INDEXES.items()}
INDEX_SIMPLE = {name: str(meta["simple_url"]) for name, meta in auth.INDEXES.items()}
@dataclass
@@ -49,9 +44,22 @@ class Options:
version: str
apply: bool = False
confirm_pypi: bool = False
repository: str = "testpypi"
verify_install: bool = True
skip_check: bool = False
# Upload straight to PyPI, skipping the TestPyPI rehearsal. Needed when you
# only hold a production token, since the two indexes reject each other's.
pypi_only: bool = False
# Offer to enter a missing token now instead of just failing.
prompt_credentials: bool = False
# Token supplied non-interactively (e.g. --token-stdin), used once.
token: str | None = None
def upload_phases(options: Options) -> list[str]:
"""The indexes to upload to, in order."""
if options.pypi_only:
return ["pypi"]
return ["testpypi", "pypi"] if options.confirm_pypi else ["testpypi"]
@dataclass
@@ -76,24 +84,78 @@ def twine_upload_argv(wheels: list[Path], repository: str | None) -> list[str]:
return argv
def credentials_hint() -> str | None:
"""Return guidance when twine has no obvious credentials, else ``None``.
def credentials_hint(index: str) -> str | None:
"""Guidance when ``index`` has no usable credentials, else ``None``.
twine itself reads ``~/.pypirc`` or ``TWINE_USERNAME``/``TWINE_PASSWORD``;
failing here is much better than failing halfway through an upload.
More than "does the file exist": a placeholder or the wrong ``username`` is
treated as missing, because twine would only find out at upload time.
"""
import os
if os.environ.get("TWINE_PASSWORD") or os.environ.get("TWINE_USERNAME"):
state = auth.credential_state(index)
if state.configured:
return None
pypirc = Path.home() / ".pypirc"
if pypirc.is_file():
return None
return (
"no PyPI credentials found: create ~/.pypirc with [testpypi] and [pypi] "
"sections, or set TWINE_USERNAME=__token__ and TWINE_PASSWORD=<token>. "
"Never commit the token."
lines = [f"no usable credentials for [{index}]: {state.problem}"]
lines.append(f" set it up with: python build/run.py release auth --set-token {index}")
if index == "testpypi":
lines.append(
" (TestPyPI needs its own account and token; if you only hold a "
"production token, use --pypi-only to skip the rehearsal)"
)
lines.append(
" or set TWINE_USERNAME=__token__ and TWINE_PASSWORD for this session only"
)
return "\n".join(lines)
def _ensure_credentials(
indexes: list[str], options: Options, *, interactive: bool | None = None
) -> bool:
"""Make sure every index that will be uploaded to has credentials.
Prompts for a missing token when there is a terminal to prompt on; a piped or
CI run fails fast instead of hanging on a hidden-input prompt.
"""
missing = [index for index in indexes if not auth.credential_state(index).configured]
if not missing:
for index in indexes:
state = auth.credential_state(index)
print(f"[ok ] credentials for [{index}] ({state.source})")
return True
if interactive is None:
interactive = sys.stdin.isatty()
allow_prompt = options.prompt_credentials or (interactive and options.apply)
if not allow_prompt:
for index in missing:
print(f"[{'FAIL' if options.apply else 'warn'}] {credentials_hint(index)}")
if not options.apply:
print(
"[info] dry run continues so you can review the plan; "
"--apply would stop here until the credentials exist"
)
return True
if not interactive:
print(
"[info] stdin is not a terminal, so nothing was prompted; run "
"`release auth --set-token <index>` or pass --prompt-credentials"
)
return False
for index in missing:
print(f"[warn] {credentials_hint(index)}")
token = options.token or auth.prompt_for_token(index)
if not token:
print(f"[FAIL] no token entered for [{index}]")
return False
for problem in auth.validate_token(token):
print(f"[FAIL] {problem.removeprefix('ERROR ')}")
return False
for warning in auth.token_warnings(token):
print(f"[warn] {warning.removeprefix('WARN ')}")
path = auth.write_token(index, token)
print(f"[ok ] wrote [{index}] credentials to {path}")
options.token = None # only the first index may consume a piped token
return True
def index_state(index: str, *, timeout: float = 20.0) -> IndexState:
@@ -207,15 +269,14 @@ def publish(options: Options) -> int:
return 1
# --- 2. credentials + twine check (before anything irreversible) ----
# Dry runs are previews, so missing credentials warn there and stop only when
# an upload is actually about to happen.
hint = credentials_hint()
if hint:
print(f"[{'FAIL' if options.apply else 'warn'}] {hint}")
if options.apply:
return 1
else:
print("[ok ] PyPI credentials are configured (twine will read them)")
phases = upload_phases(options)
if options.pypi_only:
print(
"[warn] --pypi-only: uploading straight to PyPI with no TestPyPI "
"rehearsal. An index version can never be reused or replaced."
)
if not _ensure_credentials(phases, options):
return 1
if not options.skip_check:
argv = twine_check_argv(wheels)
@@ -227,10 +288,6 @@ def publish(options: Options) -> int:
print("[ok ] twine check passed")
# --- 3. phase plan -------------------------------------------------
phases = ["testpypi"]
if options.confirm_pypi:
phases.append("pypi")
print()
for index in phases:
state = index_state(index)
@@ -249,7 +306,7 @@ def publish(options: Options) -> int:
print()
for index in phases:
print(f"[plan] upload {len(wheels)} wheel(s) to {index}")
if "pypi" not in phases:
if phases == ["testpypi"]:
print(
"[plan] stop after TestPyPI. Re-run with "
"`--confirm-pypi --apply` to publish to the real PyPI."
@@ -288,12 +345,15 @@ def publish(options: Options) -> int:
print(f"[FAIL] {index} artifact is not fetchable")
return 1
if "pypi" not in phases:
if phases == ["testpypi"]:
print()
print("=== release pypi: TestPyPI stage PASSED; PyPI untouched ===")
print("next: python build/run.py release pypi --confirm-pypi --apply")
return 0
print()
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
if options.pypi_only:
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
else:
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
return 0