9c951e9d3c
补齐全部架构的运行证据、GitHub 发布说明和 PyPI 逐文件下载核验。已通过 161 项测试、代码和文档门禁、九个 wheel 的本地索引下载核验,以及 ARM 三个 ABI 的 40 帧 CPU 检查。
422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""Command line for ``python build/run.py release``.
|
|
|
|
Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``auth`` (PyPI
|
|
credentials in ~/.pypirc), ``github`` (sync the public repo + publish a GitHub
|
|
Release), ``pypi`` (TestPyPI then PyPI). The publish commands are dry-run unless
|
|
``--apply`` is passed, and tokens are never accepted as command-line arguments.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
_BUILD_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(_BUILD_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_BUILD_ROOT))
|
|
|
|
from common.paths import REPO_ROOT # noqa: E402
|
|
from common.verify.wheel import ( # noqa: E402
|
|
DEFAULT_RUNTIME_CONFIG,
|
|
default_verification_video,
|
|
verify_wheel,
|
|
verify_wheel_assets,
|
|
verify_wheel_runtime,
|
|
)
|
|
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
|
|
|
from release import auth, github, pypi # noqa: E402
|
|
from release.preflight import PreflightOptions, run_preflight # noqa: E402
|
|
|
|
|
|
def _cmd_preflight(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="build/run.py release preflight",
|
|
description="Gate a release locally: version, tests, wheel content, runtime.",
|
|
)
|
|
parser.add_argument("--version", help="release version (default: pyproject's dynamic version)")
|
|
parser.add_argument("--wheel-dir", type=Path, help="directory to inspect (default: SDK/<version>)")
|
|
parser.add_argument("--video", type=Path, help="clip for the end-to-end runtime check")
|
|
parser.add_argument("--config", default=DEFAULT_RUNTIME_CONFIG, help="config name for that check")
|
|
parser.add_argument("--frames", type=int, default=40, help="frames to process (default: 40)")
|
|
parser.add_argument("--skip-tests", action="store_true", help="skip pytest (recorded in the report)")
|
|
parser.add_argument("--longrun", action="store_true", help="include the slow long-run stability test")
|
|
parser.add_argument("--allow-dirty", action="store_true", help="allow a dirty working tree")
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="accept a version that a git tag already marks as released",
|
|
)
|
|
parser.add_argument("--check-pypi", action="store_true", help="read PyPI and fail if the version exists")
|
|
parser.add_argument(
|
|
"--all-wheel-pythons",
|
|
action="store_true",
|
|
help="verify every staged wheel (Windows conda environments and Linux Docker images)",
|
|
)
|
|
parser.add_argument("--offline", action="store_true", help="reuse parent-env deps instead of PyPI")
|
|
parser.add_argument("--no-runtime", action="store_true", help="skip the isolated runtime check")
|
|
parser.add_argument("--no-assets", action="store_true", help="skip the bundled-asset check")
|
|
parser.add_argument("--keep-going", action="store_true", help="collect every failure instead of stopping")
|
|
parser.add_argument("--report-dir", type=Path, help="override build/release/reports")
|
|
args = parser.parse_args(argv)
|
|
|
|
options = PreflightOptions(
|
|
version=args.version,
|
|
wheel_dir=args.wheel_dir,
|
|
video=args.video,
|
|
config_name=args.config,
|
|
frames=args.frames,
|
|
skip_tests=args.skip_tests,
|
|
longrun=args.longrun,
|
|
allow_dirty=args.allow_dirty,
|
|
force=args.force,
|
|
check_pypi=args.check_pypi,
|
|
all_wheel_pythons=args.all_wheel_pythons,
|
|
offline=args.offline,
|
|
keep_going=args.keep_going,
|
|
report_dir=args.report_dir,
|
|
no_runtime=args.no_runtime,
|
|
no_assets=args.no_assets,
|
|
)
|
|
return run_preflight(options, repo_root=REPO_ROOT)
|
|
|
|
|
|
def _cmd_audit(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="build/run.py release audit",
|
|
description="Inspect one built wheel without a release context.",
|
|
)
|
|
parser.add_argument("wheel", type=Path, help="path to an .whl")
|
|
parser.add_argument("--expected-version", help="assert the wheel reports this version")
|
|
parser.add_argument(
|
|
"--strict-bundle",
|
|
action="store_true",
|
|
help="treat bundled-file drift from the current source tree as a failure",
|
|
)
|
|
parser.add_argument("--runtime", action="store_true", help="also install and run the wheel")
|
|
parser.add_argument("--assets", action="store_true", help="also assert bundled assets load")
|
|
parser.add_argument("--video", type=Path, help="clip for --runtime")
|
|
parser.add_argument("--config", default=DEFAULT_RUNTIME_CONFIG)
|
|
parser.add_argument("--frames", type=int, default=40)
|
|
parser.add_argument("--offline", action="store_true")
|
|
parser.add_argument(
|
|
"--python",
|
|
type=Path,
|
|
default=None,
|
|
help="interpreter to build the verification venv from (default: the running one)",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
wheel = args.wheel
|
|
if not wheel.is_file():
|
|
print(f"[ERROR] wheel not found: {wheel}", file=sys.stderr)
|
|
return 2
|
|
|
|
exit_code = 0
|
|
|
|
result = inspect_wheel(
|
|
wheel,
|
|
expected_version=args.expected_version,
|
|
repo_root=REPO_ROOT,
|
|
strict_bundle=args.strict_bundle,
|
|
)
|
|
print(result.render())
|
|
if not result.ok:
|
|
exit_code = 1
|
|
|
|
if args.assets and args.runtime:
|
|
# One venv for both checks: building it (and downloading dependencies in
|
|
# online mode) is the expensive part.
|
|
video = args.video or default_verification_video(REPO_ROOT)
|
|
if video is None:
|
|
print("[FAIL] runtime: no verification clip; pass --video or set ORISYS_VERIFY_VIDEO")
|
|
return 1
|
|
try:
|
|
record = verify_wheel(
|
|
wheel,
|
|
video=video,
|
|
config_name=args.config,
|
|
frames=args.frames,
|
|
python_executable=args.python,
|
|
offline=args.offline,
|
|
)
|
|
print(f"[ok ] {record['imports']}")
|
|
print(f"[ok ] {record['assets']}")
|
|
print(f"[ok ] runtime: frames={record['frames']} flow={record['flow_shape']}")
|
|
except Exception as exc:
|
|
print(f"[FAIL] isolated verification: {type(exc).__name__}: {exc}")
|
|
exit_code = 1
|
|
return exit_code
|
|
|
|
if args.assets:
|
|
try:
|
|
print(verify_wheel_assets(wheel, python_executable=args.python))
|
|
except Exception as exc:
|
|
print(f"[FAIL] bundled assets: {type(exc).__name__}: {exc}")
|
|
exit_code = 1
|
|
|
|
if args.runtime:
|
|
video = args.video or default_verification_video(REPO_ROOT)
|
|
if video is None:
|
|
print("[FAIL] runtime: no verification clip; pass --video or set ORISYS_VERIFY_VIDEO")
|
|
return 1
|
|
try:
|
|
info = verify_wheel_runtime(
|
|
wheel,
|
|
video=video,
|
|
config_name=args.config,
|
|
frames=args.frames,
|
|
python_executable=args.python,
|
|
offline=args.offline,
|
|
)
|
|
print(f"[ok ] runtime: {info}")
|
|
except Exception as exc:
|
|
print(f"[FAIL] runtime: {type(exc).__name__}: {exc}")
|
|
exit_code = 1
|
|
|
|
return exit_code
|
|
|
|
|
|
def _cmd_github(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="build/run.py release github",
|
|
description="Sync orisys-sdk-release, tag, and publish a GitHub Release.",
|
|
)
|
|
parser.add_argument(
|
|
"--version", required=True, help="release version, e.g. <major>.<minor>.<patch>"
|
|
)
|
|
parser.add_argument(
|
|
"--apply",
|
|
action="store_true",
|
|
help="actually mutate the release repo, push, and create the release",
|
|
)
|
|
parser.add_argument("--notes-file", type=Path, default=None, help="release notes markdown")
|
|
parser.add_argument(
|
|
"--attach-preflight",
|
|
action="store_true",
|
|
help="also upload the preflight report as a release asset (contains local paths)",
|
|
)
|
|
parser.add_argument(
|
|
"--allow-dirty-release",
|
|
action="store_true",
|
|
help="proceed even if the release repo has uncommitted changes",
|
|
)
|
|
parser.add_argument("--skip-fetch", action="store_true", help="skip `git fetch --tags`")
|
|
parser.add_argument(
|
|
"--skip-checks", action="store_true", help="skip the manifest's downstream checks"
|
|
)
|
|
parser.add_argument(
|
|
"--skip-release-upload",
|
|
action="store_true",
|
|
help="stop after pushing the tag and print the `gh release create` command",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
return github.publish(
|
|
github.Options(
|
|
version=args.version,
|
|
apply=args.apply,
|
|
notes_file=args.notes_file,
|
|
attach_preflight=args.attach_preflight,
|
|
allow_dirty_release=args.allow_dirty_release,
|
|
skip_fetch=args.skip_fetch,
|
|
skip_checks=args.skip_checks,
|
|
skip_release_upload=args.skip_release_upload,
|
|
)
|
|
)
|
|
|
|
|
|
def _cmd_auth(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="build/run.py release auth",
|
|
description=(
|
|
"Inspect or set up PyPI credentials in ~/.pypirc. "
|
|
"Tokens are read from a hidden prompt or stdin, never from argv."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--set-token",
|
|
choices=sorted(auth.INDEXES),
|
|
default=None,
|
|
help="store a token for this index (prompts unless --token-stdin)",
|
|
)
|
|
parser.add_argument(
|
|
"--token-stdin",
|
|
action="store_true",
|
|
help="read the token from stdin instead of prompting (for scripts/CI)",
|
|
)
|
|
parser.add_argument(
|
|
"--repair",
|
|
action="store_true",
|
|
help=(
|
|
"add existing sections to [distutils] index-servers; twine ignores "
|
|
"sections that are not listed there"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--index",
|
|
choices=sorted(auth.INDEXES),
|
|
default="pypi",
|
|
help="index to report on when not setting a token (default: pypi)",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.repair:
|
|
path = auth.pypirc_path()
|
|
added = auth.repair_index_servers(path)
|
|
if added:
|
|
print(f"[ok ] added {added} to [distutils] index-servers in {path}")
|
|
else:
|
|
print(f"[ok ] nothing to repair in {path}")
|
|
for index in sorted(auth.INDEXES):
|
|
state = auth.credential_state(index)
|
|
flag = "[ok ]" if state.configured else "[-- ]"
|
|
detail = state.source if state.configured else state.problem
|
|
print(f"{flag} [{index}] {detail}")
|
|
return 0 if auth.credential_state(args.index).configured else 1
|
|
|
|
if args.set_token:
|
|
if args.token_stdin:
|
|
token = auth.read_token_from_stdin()
|
|
else:
|
|
token = auth.prompt_for_token(args.set_token)
|
|
if not token:
|
|
print("[FAIL] no token entered")
|
|
return 1
|
|
for problem in auth.validate_token(token):
|
|
print(f"[FAIL] {problem.removeprefix('ERROR ')}")
|
|
return 1
|
|
for warning in auth.token_warnings(token):
|
|
print(f"[warn] {warning.removeprefix('WARN ')}")
|
|
path = auth.write_token(args.set_token, token)
|
|
print(f"[ok ] wrote [{args.set_token}] credentials to {path}")
|
|
state = auth.credential_state(args.set_token)
|
|
if not state.configured:
|
|
print(f"[FAIL] the file still does not validate: {state.problem}")
|
|
return 1
|
|
print(f"[ok ] [{args.set_token}] credentials verified ({state.source})")
|
|
return 0
|
|
|
|
path = auth.pypirc_path()
|
|
print(f"config file: {path} ({'exists' if path.is_file() else 'missing'})")
|
|
for index in sorted(auth.INDEXES):
|
|
state = auth.credential_state(index)
|
|
if state.configured:
|
|
print(f"[ok ] [{index}] configured ({state.source})")
|
|
else:
|
|
print(f"[-- ] [{index}] not usable: {state.problem}")
|
|
|
|
unlisted = auth.unlisted_sections(path)
|
|
if unlisted:
|
|
print()
|
|
print(
|
|
f"[warn] {unlisted} have sections that [distutils] index-servers does not "
|
|
"list; twine ignores those entirely"
|
|
)
|
|
print(" fix with: python build/run.py release auth --repair")
|
|
|
|
print()
|
|
print("set a token with:")
|
|
for index in sorted(auth.INDEXES):
|
|
if not auth.credential_state(index).configured:
|
|
print(f" python build/run.py release auth --set-token {index}")
|
|
print(" (TestPyPI needs its own account; if you only hold a production token,")
|
|
print(" skip it and publish with `release pypi --pypi-only`)")
|
|
|
|
return 0 if auth.credential_state(args.index).configured else 1
|
|
|
|
|
|
def _cmd_pypi(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="build/run.py release pypi",
|
|
description="Upload the certified wheels to TestPyPI and then PyPI.",
|
|
)
|
|
parser.add_argument(
|
|
"--version", required=True, help="release version, e.g. <major>.<minor>.<patch>"
|
|
)
|
|
parser.add_argument(
|
|
"--apply",
|
|
action="store_true",
|
|
help="actually upload (uploads are permanent and cannot be overwritten)",
|
|
)
|
|
parser.add_argument(
|
|
"--confirm-pypi",
|
|
action="store_true",
|
|
help="after TestPyPI, also publish to the real PyPI",
|
|
)
|
|
parser.add_argument(
|
|
"--pypi-only",
|
|
action="store_true",
|
|
help=(
|
|
"upload straight to PyPI, skipping the TestPyPI rehearsal "
|
|
"(for when you only hold a production token)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--prompt-credentials",
|
|
action="store_true",
|
|
help="if a token is missing, prompt for it and store it in ~/.pypirc",
|
|
)
|
|
parser.add_argument(
|
|
"--token-stdin",
|
|
action="store_true",
|
|
help="read a missing token from stdin instead of prompting (scripts/CI)",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-install-verify",
|
|
action="store_true",
|
|
help="skip fetching the artifact back out of the index",
|
|
)
|
|
parser.add_argument("--skip-check", action="store_true", help="skip `twine check`")
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.pypi_only and args.confirm_pypi:
|
|
parser.error("--pypi-only and --confirm-pypi are mutually exclusive")
|
|
|
|
if args.token_stdin and not args.prompt_credentials:
|
|
parser.error("--token-stdin requires --prompt-credentials")
|
|
|
|
token = auth.read_token_from_stdin() if args.token_stdin else None
|
|
|
|
return pypi.publish(
|
|
pypi.Options(
|
|
version=args.version,
|
|
apply=args.apply,
|
|
confirm_pypi=args.confirm_pypi,
|
|
verify_install=not args.skip_install_verify,
|
|
skip_check=args.skip_check,
|
|
pypi_only=args.pypi_only,
|
|
prompt_credentials=args.prompt_credentials,
|
|
token=token,
|
|
)
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
if not argv or argv[0] in ("-h", "--help"):
|
|
print(__doc__)
|
|
print("Commands: preflight | audit | auth | github | pypi")
|
|
return 0
|
|
|
|
command, rest = argv[0], argv[1:]
|
|
if command == "preflight":
|
|
return _cmd_preflight(rest)
|
|
if command == "audit":
|
|
return _cmd_audit(rest)
|
|
if command == "auth":
|
|
return _cmd_auth(rest)
|
|
if command == "github":
|
|
return _cmd_github(rest)
|
|
if command == "pypi":
|
|
return _cmd_pypi(rest)
|
|
|
|
print(f"Unknown release command: {command!r}", file=sys.stderr)
|
|
print("Choose from: preflight, audit, auth, github, pypi", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|