automate publishing: release github + release pypi

This commit is contained in:
ykcheungab
2026-09-12 16:50:55 +08:00
parent 388f34ca85
commit 174bceabc4
10 changed files with 1395 additions and 55 deletions
+15
View File
@@ -72,6 +72,21 @@ python build/run.py release preflight --all-wheel-pythons --check-pypi
Reports land in `build/release/reports/<version>/`. Reports land in `build/release/reports/<version>/`.
### Publish
Both default to a dry run; `--apply` is required for anything irreversible.
```bash
python build/run.py release github --version <version> # preview
python build/run.py release github --version <version> --apply # sync + tag + push + Release
python build/run.py release pypi --version <version> --apply # TestPyPI only
python build/run.py release pypi --version <version> --confirm-pypi --apply # + PyPI
```
Both refuse to act unless `release preflight` passed on a clean commit with no
waived options, and every wheel still hashes to what that report recorded.
See `docs/publishing.md` §3.
### GUI ### GUI
```bash ```bash
+99 -3
View File
@@ -1,4 +1,9 @@
"""Command line for ``python build/run.py release``.""" """Command line for ``python build/run.py release``.
Commands: ``preflight`` (local gate), ``audit`` (single wheel), ``github`` (sync
the public repo + publish a GitHub Release), ``pypi`` (TestPyPI then PyPI).
The publish commands are dry-run unless ``--apply`` is passed.
"""
from __future__ import annotations from __future__ import annotations
@@ -19,6 +24,7 @@ from common.verify.wheel import ( # noqa: E402
verify_wheel_runtime, verify_wheel_runtime,
) )
from common.verify.wheel_contents import inspect_wheel # noqa: E402 from common.verify.wheel_contents import inspect_wheel # noqa: E402
from release import github, pypi # noqa: E402
from release.preflight import PreflightOptions, run_preflight # noqa: E402 from release.preflight import PreflightOptions, run_preflight # noqa: E402
@@ -170,11 +176,97 @@ def _cmd_audit(argv: list[str]) -> int:
return exit_code 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. 0.5.2")
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_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. 0.5.2")
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(
"--repository", default="testpypi", help="twine repository for the first phase"
)
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)
return pypi.publish(
pypi.Options(
version=args.version,
apply=args.apply,
confirm_pypi=args.confirm_pypi,
repository=args.repository,
verify_install=not args.skip_install_verify,
skip_check=args.skip_check,
)
)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv) argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"): if not argv or argv[0] in ("-h", "--help"):
print(__doc__) print(__doc__)
print("Commands: preflight | audit") print("Commands: preflight | audit | github | pypi")
return 0 return 0
command, rest = argv[0], argv[1:] command, rest = argv[0], argv[1:]
@@ -182,9 +274,13 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_preflight(rest) return _cmd_preflight(rest)
if command == "audit": if command == "audit":
return _cmd_audit(rest) return _cmd_audit(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(f"Unknown release command: {command!r}", file=sys.stderr)
print("Choose from: preflight, audit", file=sys.stderr) print("Choose from: preflight, audit, github, pypi", file=sys.stderr)
return 2 return 2
+369
View File
@@ -0,0 +1,369 @@
"""Sync the public release repo and publish a GitHub Release.
``python build/run.py release github`` runs, in order:
1. certified-wheels gate (a passing ``release preflight`` report must exist and
every wheel must still hash to what it recorded)
2. refuse if the version is already tagged or already has a GitHub Release
3. sync the open-source subset of this checkout into ``orisys-sdk-release``
(``tools/sync_downstreams.py`` + the manifest's ``checks``)
4. stage ``SDK/<version>/*.whl`` into ``orisys-sdk-release/release/<version>/``
5. commit, tag ``v<version>``, push branch + tag
6. create the GitHub Release with the wheels attached, then verify the assets
Nothing mutates anything unless ``--apply`` is passed: the default is a dry run
that prints every command it would run.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
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 release.publish import GateResult, PublishGateError, load_gate # noqa: E402
MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json"
SYNC_TOOL = REPO_ROOT / "tools" / "sync_downstreams.py"
RELEASE_TARGET = "orisys-sdk-release"
@dataclass
class Options:
version: str
apply: bool = False
notes_file: Path | None = None
attach_preflight: bool = False
allow_dirty_release: bool = False
skip_fetch: bool = False
skip_checks: bool = False
skip_release_upload: bool = False
@dataclass
class CommandStep:
description: str
argv: list[str]
cwd: Path | None = None
@dataclass
class Plan:
release_repo: Path
branch: str
tag: str
assets: list[Path] = field(default_factory=list)
steps: list[CommandStep] = field(default_factory=list)
def manifest_entry() -> dict:
"""The manifest entry for the public release repo."""
payload = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
for item in payload.get("downstreams", []):
if item.get("name") == RELEASE_TARGET:
return item
raise PublishGateError(f"{MANIFEST_PATH} has no downstream named {RELEASE_TARGET!r}")
def release_repo_path() -> Path:
return (REPO_ROOT / manifest_entry()["path"]).resolve()
def _git(repo: Path, *args: str, check: bool = True) -> str:
completed = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
check=False,
)
if check and completed.returncode != 0:
raise PublishGateError(
f"git {' '.join(args)} failed in {repo}:\n{completed.stderr.strip()}"
)
return (completed.stdout or "").strip()
def build_release_notes(version: str, wheels: list[Path], git_commit: str | None) -> str:
lines = [
f"orisys {version}",
"",
"Wheels (Python 3.10 / 3.11 / 3.12, Windows x64 and Linux x86_64):",
]
lines.extend(f"- `{wheel.name}`" for wheel in wheels)
lines.append("")
if git_commit:
lines.append(
f"Built from `{git_commit[:12]}` and certified by `release preflight`."
)
return "\n".join(lines) + "\n"
def build_github_steps(
*,
version: str,
branch: str,
tag: str,
assets: list[Path],
notes_file: Path | None,
title: str | None = None,
) -> list[CommandStep]:
"""The mutating commands, as pure data so they can be asserted in tests."""
release_argv = [
"gh",
"release",
"create",
tag,
"--verify-tag",
"--title",
title or f"v{version}",
"--latest",
]
if notes_file is not None:
release_argv += ["--notes-file", str(notes_file)]
release_argv += [str(asset) for asset in assets]
return [
CommandStep("create tag", ["git", "tag", tag]),
CommandStep("push branch", ["git", "push", "origin", branch]),
CommandStep("push tag", ["git", "push", "origin", tag]),
CommandStep("create GitHub Release with assets", release_argv),
]
def _check_gh() -> list[str]:
"""Problems that would stop the release step, reported before any mutation."""
problems: list[str] = []
if shutil.which("gh") is None:
problems.append(
"the `gh` CLI is not on PATH. Install it (`winget install --id GitHub.cli`) "
"and authenticate (`gh auth login`), or use --skip-release-upload"
)
return problems
completed = subprocess.run(
["gh", "auth", "status"], capture_output=True, text=True, check=False
)
if completed.returncode != 0:
problems.append("`gh auth status` failed; run `gh auth login` first")
return problems
def _existing_release_tags(repo: Path, version: str) -> list[str]:
wanted = {version, f"v{version}"}
tags = set(_git(repo, "tag", "--list").splitlines())
return sorted(wanted & tags)
def _release_exists(tag: str) -> bool:
completed = subprocess.run(
["gh", "release", "view", tag, "--json", "tagName"],
capture_output=True,
text=True,
check=False,
)
return completed.returncode == 0
def _sync(*, run_checks: bool) -> None:
"""Copy the public subset into the release repo and run its checks."""
python = sys.executable
for command in (
[python, str(SYNC_TOOL), "apply", "--target", RELEASE_TARGET],
*(
[[python, str(SYNC_TOOL), "run-checks", "--target", RELEASE_TARGET]]
if run_checks
else []
),
):
completed = subprocess.run(command, cwd=REPO_ROOT, check=False)
if completed.returncode != 0:
raise PublishGateError(
f"`{' '.join(command[1:])}` failed with exit {completed.returncode}"
)
def _stage_wheels(wheels: list[Path], asset_dir: Path) -> None:
asset_dir.mkdir(parents=True, exist_ok=True)
for wheel in wheels:
shutil.copy2(wheel, asset_dir / wheel.name)
def publish(options: Options) -> int:
version = options.version
mode = "APPLY" if options.apply else "DRY RUN"
print(f"=== release github: orisys {version} ({mode}) ===")
# --- 1. gate -------------------------------------------------------
try:
gate: GateResult = load_gate(REPO_ROOT, version)
except PublishGateError as exc:
print(f"[FAIL] {exc}")
return 1
print(f"[ok ] preflight report: {gate.report_path}")
print(f" certified commit: {gate.git_commit}")
for wheel in gate.wheels:
print(f" [ok] {wheel.name} sha256={wheel.sha256[:12]} {wheel.size} bytes")
for warning in gate.warnings:
print(f"[warn] {warning}")
# --- 2. target repo ------------------------------------------------
try:
repo = release_repo_path()
except PublishGateError as exc:
print(f"[FAIL] {exc}")
return 1
if not (repo / ".git").exists():
print(f"[FAIL] {repo} is not a git repository")
return 1
try:
if not options.skip_fetch:
_git(repo, "fetch", "--tags", "--quiet")
print(f"[ok ] git fetch --tags in {repo}")
branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD")
dirty = _git(repo, "status", "--porcelain")
except PublishGateError as exc:
print(f"[FAIL] {exc}")
return 1
if dirty and not options.allow_dirty_release:
preview = "\n".join(dirty.splitlines()[:10])
print(
f"[FAIL] {repo} has uncommitted changes; commit or stash them first "
f"(or pass --allow-dirty-release):\n{preview}"
)
return 1
print(f"[ok ] release repo: {repo} ({branch}) clean={not dirty}")
tag = f"v{version}"
existing = _existing_release_tags(repo, version)
if existing:
print(
f"[FAIL] tag(s) {existing} already exist in {repo}; "
f"{version} looks already released"
)
return 1
if shutil.which("gh") and _release_exists(tag):
print(f"[FAIL] GitHub Release {tag} already exists")
return 1
print(f"[ok ] no existing tag or release for {tag}")
# A dry run is a preview of what would happen, so missing tooling is a warning
# there and a hard stop only when we are about to mutate something.
if options.skip_release_upload:
print("[warn] --skip-release-upload: the GitHub Release will be left to you")
else:
gh_problems = _check_gh()
if gh_problems:
for problem in gh_problems:
print(f"[{'FAIL' if options.apply else 'warn'}] {problem}")
if options.apply:
return 1
else:
print("[ok ] gh CLI is installed and authenticated")
# --- 3. plan the mutating steps ------------------------------------
wheels = [REPO_ROOT / "SDK" / version / record.name for record in gate.wheels]
missing = [str(wheel) for wheel in wheels if not wheel.is_file()]
if missing:
print(f"[FAIL] wheels missing from disk: {missing}")
return 1
asset_dir = repo / "release" / version
assets = [asset_dir / wheel.name for wheel in wheels]
notes_file = options.notes_file
steps = build_github_steps(
version=version, branch=branch, tag=tag, assets=assets, notes_file=notes_file
)
print()
print(f"[plan] sync {len(manifest_entry().get('sync', []))} entry(ies) into {repo}")
print(f"[plan] stage {len(wheels)} wheel(s) into {asset_dir}")
print(f"[plan] commit {repo} and tag {tag}")
for step in steps:
manual = options.skip_release_upload and step.description.startswith(
"create GitHub Release"
)
prefix = "[plan] (left manual)" if manual else "[plan]"
print(f"{prefix} {step.description}: {' '.join(step.argv)}")
print()
if not options.apply:
print("dry run: nothing was changed. Re-run with --apply to execute.")
return 0
# --- 4. execute ----------------------------------------------------
try:
commit_before = _git(repo, "rev-parse", "HEAD")
_sync(run_checks=not options.skip_checks)
_stage_wheels(wheels, asset_dir)
if options.attach_preflight:
shutil.copy2(gate.report_path, asset_dir / f"{version}-preflight.json")
_git(repo, "add", "-A")
pending = _git(repo, "status", "--porcelain")
if pending:
_git(repo, "commit", "-m", f"release {version}")
print(f"[ok ] committed {len(pending.splitlines())} change(s) in {repo}")
else:
print("[ok ] nothing new to commit in the release repo")
_git(repo, "tag", tag)
_git(repo, "push", "origin", branch)
_git(repo, "push", "origin", tag)
print(f"[ok ] pushed {branch} and {tag}")
except PublishGateError as exc:
print(f"[FAIL] {exc}")
return 1
release_argv = next(
step.argv for step in steps if step.description.startswith("create GitHub Release")
)
if options.skip_release_upload:
print()
print("[todo] publish the release yourself:")
print(" " + " ".join(release_argv))
print()
print("=== release github: repo synced and tagged; release upload left manual ===")
return 0
completed = subprocess.run(release_argv, check=False)
if completed.returncode != 0:
print(f"[FAIL] gh release create exited {completed.returncode}")
return 1
print(f"[ok ] GitHub Release {tag} created")
# --- 5. verify the published assets --------------------------------
completed = subprocess.run(
["gh", "release", "view", tag, "--json", "assets,tagName"],
capture_output=True,
text=True,
check=False,
)
if completed.returncode != 0:
print("[FAIL] could not read back the release to verify assets")
return 1
payload = json.loads(completed.stdout or "{}")
published = {asset["name"] for asset in payload.get("assets", [])}
expected = {asset.name for asset in assets}
if options.attach_preflight:
expected.add(f"{version}-preflight.json")
if published != expected:
print(
f"[FAIL] release asset mismatch:\n expected {sorted(expected)}\n"
f" published {sorted(published)}"
)
return 1
print(f"[ok ] verified {len(published)} asset(s) on the release")
print()
print(f"=== release github PASSED (commit {commit_before[:12]} -> {_git(repo, 'rev-parse', 'HEAD')[:12]}) ===")
return 0
+3 -1
View File
@@ -16,7 +16,9 @@ Steps, in order:
8. ``wheel-runtime`` - one isolated venv per wheel: bundled assets load and 8. ``wheel-runtime`` - one isolated venv per wheel: bundled assets load and
real frames flow through the public pipeline real frames flow through the public pipeline
Output lands in ``build/release/reports/<version>/preflight.{json,txt}``. Output lands in ``build/release/reports/<version>/preflight.{json,txt}``, and is
the contract the publish commands (``release github`` / ``release pypi``) consume:
they refuse to ship anything this run did not certify.
""" """
from __future__ import annotations from __future__ import annotations
+158
View File
@@ -0,0 +1,158 @@
"""Shared publish gate: certify a release from its preflight report.
Both publish commands (``release github`` and ``release pypi``) refuse to act
unless a passing ``release preflight`` report exists for the exact version and
every wheel still hashes to what that report recorded. The report is the contract
between "we verified this" and "we shipped this"; re-deriving the checks here
would just be a second, weaker opinion.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
# Options that weaken the gate. A release must not be certified by a run that
# waived any of them: re-run preflight on a clean commit instead.
WAIVED_GUARD_KEYS = ("skip_tests", "allow_dirty", "force", "no_runtime", "no_assets")
class PublishGateError(RuntimeError):
"""The preflight report does not certify these wheels."""
@dataclass
class WheelRecord:
name: str
sha256: str
size: int = 0
@dataclass
class GateResult:
version: str
git_commit: str | None
report_path: Path
wheels: list[WheelRecord] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def evaluate_gate(
payload: dict,
version: str,
wheels: dict[str, Path],
*,
head_commit: str | None = None,
) -> tuple[list[str], list[str]]:
"""Return ``(problems, warnings)`` for a report payload and the wheels on disk.
Pure function: no I/O beyond hashing the wheels, so it is cheap to test.
"""
problems: list[str] = []
warnings: list[str] = []
if str(payload.get("version")) != version:
problems.append(
f"report is for version {payload.get('version')!r}, not {version!r}"
)
if not payload.get("ok"):
problems.append(
"report result is FAIL - run `release preflight --keep-going` and fix "
"everything before publishing"
)
if payload.get("aborted"):
problems.append("report stopped early (aborted); it certifies nothing")
options = payload.get("options") or {}
waived = sorted(key for key in WAIVED_GUARD_KEYS if options.get(key))
if waived:
problems.append(
"report waived gate options "
+ ", ".join(f"--{key.replace('_', '-')}" for key in waived)
+ "; commit the tree and re-run `release preflight` without them"
)
recorded = {item.get("name"): item.get("sha256") for item in payload.get("wheels") or []}
if not recorded:
problems.append("report lists no wheels")
missing = sorted(set(recorded) - set(wheels))
if missing:
problems.append(f"wheels in the report are missing from disk: {missing}")
extra = sorted(set(wheels) - set(recorded))
if extra:
problems.append(f"wheels on disk are not covered by the report: {extra}")
for name in sorted(set(recorded) & set(wheels)):
actual = sha256_file(wheels[name])
if actual != recorded[name]:
problems.append(
f"{name} changed after preflight: report {recorded[name][:12]}, "
f"disk {actual[:12]} - rebuild and re-run preflight"
)
commit = payload.get("git", {}).get("commit") if isinstance(payload.get("git"), dict) else None
if head_commit and commit and commit != head_commit:
warnings.append(
f"report certifies commit {commit[:12]} but HEAD is {head_commit[:12]}; "
"the wheels are unchanged, so this is usually just a later commit"
)
return problems, warnings
def load_gate(repo_root: Path, version: str) -> GateResult:
"""Load and enforce the preflight report for ``version``.
Raises :class:`PublishGateError` with every problem found, so one run reports
all of them instead of stopping at the first.
"""
repo_root = Path(repo_root)
report_path = repo_root / "build" / "release" / "reports" / version / "preflight.json"
if not report_path.is_file():
raise PublishGateError(
f"no preflight report at {report_path}. Run:\n"
f" python build/run.py release preflight\n"
f"(the publish commands only ship what preflight certified)"
)
try:
payload = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PublishGateError(f"cannot read {report_path}: {exc}") from exc
wheel_dir = repo_root / "SDK" / version
wheels = {path.name: path for path in sorted(wheel_dir.glob("*.whl"))}
if not wheels:
raise PublishGateError(
f"no wheels in {wheel_dir}; build first (`python build/run.py wheel-win` / "
"`manylinux`) and re-run preflight"
)
problems, warnings = evaluate_gate(payload, version, wheels)
if problems:
raise PublishGateError(
f"the preflight report at {report_path} does not certify orisys {version}:\n "
+ "\n ".join(problems)
)
git_payload = payload.get("git") if isinstance(payload.get("git"), dict) else {}
return GateResult(
version=version,
git_commit=git_payload.get("commit"),
report_path=report_path,
wheels=[
WheelRecord(name=name, sha256=sha256_file(path), size=path.stat().st_size)
for name, path in wheels.items()
],
warnings=warnings,
)
+299
View File
@@ -0,0 +1,299 @@
"""Upload the certified wheels to TestPyPI, then (on confirmation) to PyPI.
``python build/run.py release pypi`` runs:
1. certified-wheels gate (same contract as ``release github``)
2. ``twine check`` on every wheel - the wheel metadata is generated, so its
long description and classifiers are worth validating before an upload that
cannot be undone
3. refuse if the version already exists on the target index (read-only probe)
4. upload to TestPyPI, then verify by fetching it back from the index
5. stop there unless ``--confirm-pypi`` is given; then repeat 3-4 against PyPI
PyPI uploads are permanent: a version number can never be reused, and a bad
artifact cannot be replaced. Hence the TestPyPI stage, the read-only existence
probe, and ``--apply`` being required even to reach TestPyPI.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from dataclasses import dataclass, field
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 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/",
}
@dataclass
class Options:
version: str
apply: bool = False
confirm_pypi: bool = False
repository: str = "testpypi"
verify_install: bool = True
skip_check: bool = False
@dataclass
class IndexState:
versions: list[str] = field(default_factory=list)
error: str | None = None
@property
def known(self) -> bool:
return self.error is None
def twine_check_argv(wheels: list[Path]) -> list[str]:
return [sys.executable, "-m", "twine", "check", *[str(w) for w in wheels]]
def twine_upload_argv(wheels: list[Path], repository: str | None) -> list[str]:
argv = [sys.executable, "-m", "twine", "upload"]
if repository:
argv += ["--repository", repository]
argv += [str(wheel) for wheel in wheels]
return argv
def credentials_hint() -> str | None:
"""Return guidance when twine has no obvious credentials, else ``None``.
twine itself reads ``~/.pypirc`` or ``TWINE_USERNAME``/``TWINE_PASSWORD``;
failing here is much better than failing halfway through an upload.
"""
import os
if os.environ.get("TWINE_PASSWORD") or os.environ.get("TWINE_USERNAME"):
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."
)
def index_state(index: str, *, timeout: float = 20.0) -> IndexState:
"""Read the published versions of orisys from an index (read-only)."""
url = INDEX_JSON[index]
request = urllib.request.Request(
url, headers={"User-Agent": "orisys-release-publish", "Accept": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.load(response)
except urllib.error.HTTPError as exc:
if exc.code == 404:
return IndexState(versions=[])
return IndexState(error=f"{index} returned HTTP {exc.code}")
except Exception as exc: # network, DNS, TLS, JSON
return IndexState(error=f"{index} lookup failed: {type(exc).__name__}: {exc}")
releases = payload.get("releases")
if not isinstance(releases, dict):
return IndexState(error=f"unexpected {index} response (no 'releases' mapping)")
return IndexState(
versions=sorted(name for name, files in releases.items() if files)
)
def index_files(index: str, version: str, *, timeout: float = 20.0) -> list[str] | None:
"""Filenames published for ``version`` on ``index``, or ``None`` if absent."""
url = INDEX_JSON[index]
request = urllib.request.Request(
url, headers={"User-Agent": "orisys-release-publish", "Accept": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.load(response)
except Exception:
return None
files = (payload.get("releases") or {}).get(version)
if not isinstance(files, list):
return None
return sorted(item.get("filename", "") for item in files)
def _upload(wheels: list[Path], repository: str | None) -> int:
argv = twine_upload_argv(wheels, repository)
print(f"[run ] {' '.join(str(part) for part in argv)}")
completed = subprocess.run(argv, check=False)
return completed.returncode
def _fetch_back(index: str, version: str, wheel: Path) -> int:
"""Download the freshly uploaded wheel back out of the index."""
with tempfile.TemporaryDirectory(prefix="orisys-fetch-") as tmp:
target = Path(tmp) / "out"
argv = [
sys.executable,
"-m",
"pip",
"download",
"--no-deps",
"--no-cache-dir",
"--index-url",
INDEX_SIMPLE[index],
"--dest",
str(target),
f"orisys=={version}",
]
if index == "testpypi":
# TestPyPI mirrors only what was uploaded there; deps live on PyPI.
# --no-deps keeps this a pure "is the artifact fetchable" probe.
argv += ["--extra-index-url", INDEX_SIMPLE["pypi"]]
print(f"[run ] {' '.join(argv)}")
completed = subprocess.run(argv, check=False)
if completed.returncode != 0:
return completed.returncode
fetched = sorted(target.glob(f"orisys-{version}*"))
if not fetched:
print(f"[FAIL] pip download produced nothing for orisys=={version}")
return 1
print(f"[ok ] fetched {[p.name for p in fetched]} from {index}")
if wheel.name not in {path.name for path in fetched}:
print(
f"[warn] {wheel.name} was not the file fetched "
"(the index may serve a different platform first)"
)
return 0
def publish(options: Options) -> int:
version = options.version
mode = "APPLY" if options.apply else "DRY RUN"
print(f"=== release pypi: orisys {version} ({mode}) ===")
# --- 1. gate -------------------------------------------------------
try:
gate: GateResult = load_gate(REPO_ROOT, version)
except PublishGateError as exc:
print(f"[FAIL] {exc}")
return 1
print(f"[ok ] preflight report: {gate.report_path}")
for wheel in gate.wheels:
print(f" [ok] {wheel.name} sha256={wheel.sha256[:12]}")
for warning in gate.warnings:
print(f"[warn] {warning}")
wheels = [REPO_ROOT / "SDK" / version / record.name for record in gate.wheels]
missing = [str(wheel) for wheel in wheels if not wheel.is_file()]
if missing:
print(f"[FAIL] wheels missing from disk: {missing}")
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)")
if not options.skip_check:
argv = twine_check_argv(wheels)
print(f"[run ] {' '.join(str(part) for part in argv)}")
completed = subprocess.run(argv, check=False)
if completed.returncode != 0:
print("[FAIL] twine check reported problems; fix the wheel metadata first")
return 1
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)
if not state.known:
print(f"[FAIL] {state.error}; cannot safely check for duplicate uploads")
return 1
if version in state.versions:
print(
f"[FAIL] orisys {version} already exists on {index} "
f"(published: {', '.join(state.versions) or 'none'}) - "
"an index version cannot be reused"
)
return 1
print(f"[ok ] {index}: {version} not present (published: {', '.join(state.versions) or 'none'})")
print()
for index in phases:
print(f"[plan] upload {len(wheels)} wheel(s) to {index}")
if "pypi" not in phases:
print(
"[plan] stop after TestPyPI. Re-run with "
"`--confirm-pypi --apply` to publish to the real PyPI."
)
print()
if not options.apply:
print("dry run: nothing was uploaded. Re-run with --apply to execute.")
return 0
# --- 4. upload ----------------------------------------------------
for index in phases:
if _upload(wheels, index) != 0:
print(f"[FAIL] twine upload to {index} failed")
return 1
print(f"[ok ] uploaded to {index}")
published = index_files(index, version)
if published is None:
print(
f"[FAIL] {index} does not serve orisys {version} yet; "
"the upload may still be indexing, verify manually"
)
return 1
expected = {wheel.name for wheel in wheels}
if set(published) != expected:
print(
f"[FAIL] {index} file set mismatch:\n expected {sorted(expected)}\n"
f" got {sorted(published)}"
)
return 1
print(f"[ok ] {index} serves all {len(published)} file(s) for {version}")
if options.verify_install:
if _fetch_back(index, version, wheels[0]) != 0:
print(f"[FAIL] {index} artifact is not fetchable")
return 1
if "pypi" not in phases:
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) ===")
return 0
+50 -12
View File
@@ -120,24 +120,62 @@ python build/run.py verify --gui-dist --product ory_finger
--- ---
## 3. 发布(目前仍需人工) ## 3. 发布(两条命令)
阶段一不包含自动发布。当前人工步骤: 先决条件:`release preflight` PASS,**而且是在干净提交上跑的**。两个发布命令都只发布「preflight 认证过的东西」——它们会读取 `build/release/reports/<version>/preflight.json`,逐个核对 wheel 的 sha256,并拒绝使用带豁免项(`--skip-tests` / `--allow-dirty` / `--no-runtime` / `--force`)的报告。所以正确顺序是:
1. 用 `release preflight` 拿到 PASS(并保留 `preflight.json`)。
2. 把 `SDK/<version>/` 同步到 `orisys-sdk-release` 仓库的 `release/<version>/`。
3. GitHub:打 tag **`v<version>`** 并创建 Release,上传 6 个 wheel 作为附件。
- 注意历史 tag 里有一个 typo:`0.50`(与 `v0.5.1` 指向同一 commit)。
4. PyPI:先 TestPyPI 再正式仓库。
```powershell ```powershell
python -m twine upload --repository testpypi SDK/<version>/*.whl git add -A; git commit -m "..."
python -m twine upload SDK/<version>/*.whl python build/run.py wheel-win && python build/run.py manylinux
python build/run.py release preflight --all-wheel-pythons --check-pypi # 干净树 + 无豁免
``` ```
**不要把 API token 写进仓库**。`release preflight --check-pypi` 只做只读查询,不需要 token。 两个命令**默认都是 dry run**,只打印将要执行的动作;必须显式加 `--apply` 才会真正动手。GitHub 的 `gh release create`、`git push`,以及 PyPI 上传(版本号永久占用、不可覆盖)都属于不可撤销操作。
### 已知的历史发布空洞 ### 3.1 GitHub:同步公开仓库 + 上传 Release
```powershell
python build/run.py release github --version 0.5.2 # 预览
python build/run.py release github --version 0.5.2 --apply # 执行
```
执行顺序:
1. 认证书校验(上面那份报告 + 逐 wheel sha256)
2. `git fetch --tags`,若 `v0.5.2` 或 `0.5.2` 已存在则拒绝;`gh release view` 已存在也拒绝
3. 按 `tools/downstream_sync_manifest.json` 同步公开内容到 `../orisys-sdk-release`,并运行 manifest 里的 `checks`
4. 把 `SDK/0.5.2/*.whl` 放到 `orisys-sdk-release/release/0.5.2/`(该目录在 release 仓库里是 gitignore 的,只用于上传附件)
5. 提交 → 打 tag `v0.5.2` → push 分支与 tag
6. `gh release create v0.5.2 --verify-tag --latest <6 个 wheel>`,然后回读 `gh release view --json assets` 核对附件齐全
需要:`gh` 已安装并登录(`winget install --id GitHub.cli` 然后 `gh auth login`)。没装 gh 时:
- dry run 仍会完整打印计划(只告警)
- `--apply` 会失败,或改用 `--skip-release-upload`:同步、提交、打 tag、push 都自动完成,然后打印出你自己要跑的 `gh release create`
其他开关:`--notes-file`(自定义 release notes)、`--attach-preflight`(把 `preflight.json` 也作为附件上传,便于审计;注意报告里有本机绝对路径)、`--allow-dirty-release`、`--skip-fetch`、`--skip-checks`。
> tag 拼写沿用历史风格 `v<version>`(注意历史里那个 typo tag `0.50` 指向 `v0.5.1`)。命令会在动手前拒绝已存在的 tag,所以不会重复发布。
### 3.2 PyPI:TestPyPI 先行,确认后再上正式仓库
```powershell
python build/run.py release pypi --version 0.5.2 # 预览
python build/run.py release pypi --version 0.5.2 --apply # 只发 TestPyPI
python build/run.py release pypi --version 0.5.2 --confirm-pypi --apply # TestPyPI + 正式 PyPI
```
执行顺序:
1. 同样的证书校验
2. `twine check` 全部 wheel(校验 METADATA/long description,上传前就能发现问题)
3. 只读查询目标索引,确认该版本不存在(TestPyPI 与 PyPI 各查各的)
4. 上传 TestPyPI → 回读索引确认 6 个文件都在 → `pip download` 把刚上传的 wheel 拉回来,证明产物可获取
5. 只有加了 `--confirm-pypi` 才会对正式 PyPI 重复第 3-4 步;否则停在这里并打印后续命令
凭证由 twine 自己解析:`~/.pypirc` 的 `[testpypi]` / `[pypi]` 段,或 `TWINE_USERNAME=__token__` + `TWINE_PASSWORD=<token>`。命令会在**任何写操作之前**检查凭证是否配置好并给出提示。**不要把 token 写进仓库。**
### 3.3 已知的历史发布空洞
| 版本 | 本地 `SDK/` | PyPI | GitHub Release | | 版本 | 本地 `SDK/` | PyPI | GitHub Release |
| --- | --- | --- | --- | | --- | --- | --- | --- |
+312
View File
@@ -0,0 +1,312 @@
"""Tests for the publish gate, the GitHub plan and the PyPI command shape.
Nothing here touches the network, ``gh`` or an index: the mutating paths are
verified through their pure planning functions, which is exactly what the CLI
executes after the gate passes.
"""
from __future__ import annotations
import json
import sys
import urllib.error
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
BUILD_ROOT = REPO_ROOT / "build"
if str(BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(BUILD_ROOT))
from release import github, publish, pypi # noqa: E402
def _wheel(tmp_path: Path, name: str, payload: bytes = b"wheel") -> Path:
path = tmp_path / name
path.write_bytes(payload)
return path
def _report_payload(version: str, wheels: list[Path], **overrides) -> dict:
payload = {
"schema": 1,
"version": version,
"ok": True,
"aborted": False,
"git": {"commit": "a" * 40},
"options": {"skip_tests": False, "allow_dirty": False},
"wheels": [
{"name": wheel.name, "sha256": publish.sha256_file(wheel), "size": wheel.stat().st_size}
for wheel in wheels
],
}
payload.update(overrides)
return payload
# --- gate -------------------------------------------------------------------
def test_gate_passes_for_matching_wheels(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
problems, warnings = publish.evaluate_gate(
_report_payload("1.2.3", wheels), "1.2.3", {w.name: w for w in wheels}
)
assert problems == []
assert warnings == []
def test_gate_rejects_version_mismatch(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
problems, _ = publish.evaluate_gate(
_report_payload("9.9.9", wheels), "1.2.3", {w.name: w for w in wheels}
)
assert any("report is for version" in problem for problem in problems)
def test_gate_rejects_failed_report(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
problems, _ = publish.evaluate_gate(
_report_payload("1.2.3", wheels, ok=False), "1.2.3", {w.name: w for w in wheels}
)
assert any("result is FAIL" in problem for problem in problems)
def test_gate_rejects_waived_guard_options(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
payload = _report_payload("1.2.3", wheels, options={"skip_tests": True, "no_runtime": True})
problems, _ = publish.evaluate_gate(payload, "1.2.3", {w.name: w for w in wheels})
assert any("--skip-tests" in problem and "--no-runtime" in problem for problem in problems)
def test_gate_rejects_changed_wheel(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
payload = _report_payload("1.2.3", wheels)
wheels[0].write_bytes(b"tampered")
problems, _ = publish.evaluate_gate(payload, "1.2.3", {w.name: w for w in wheels})
assert any("changed after preflight" in problem for problem in problems)
def test_gate_rejects_uncertified_and_missing_wheels(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
payload = _report_payload("1.2.3", wheels)
extra = _wheel(tmp_path, "orisys-1.2.3-cp311-cp311-win_amd64.whl")
problems, _ = publish.evaluate_gate(
payload, "1.2.3", {w.name: w for w in [*wheels, extra]}
)
assert any("not covered by the report" in problem for problem in problems)
def test_gate_warns_on_commit_drift(tmp_path):
wheels = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
payload = _report_payload("1.2.3", wheels)
problems, warnings = publish.evaluate_gate(
payload, "1.2.3", {w.name: w for w in wheels}, head_commit="b" * 40
)
assert problems == []
assert any("HEAD is" in warning for warning in warnings)
def test_load_gate_requires_a_report(tmp_path, monkeypatch):
monkeypatch.setattr(publish, "REPO_ROOT", tmp_path, raising=False)
with pytest.raises(publish.PublishGateError, match="no preflight report"):
publish.load_gate(tmp_path, "1.2.3")
def test_load_gate_explains_waived_report(tmp_path):
report_dir = tmp_path / "build" / "release" / "reports" / "1.2.3"
report_dir.mkdir(parents=True)
wheel_dir = tmp_path / "SDK" / "1.2.3"
wheel_dir.mkdir(parents=True)
wheel = wheel_dir / "orisys-1.2.3-cp310-cp310-win_amd64.whl"
wheel.write_bytes(b"wheel")
(report_dir / "preflight.json").write_text(
json.dumps(_report_payload("1.2.3", [wheel], options={"skip_tests": True})),
encoding="utf-8",
)
with pytest.raises(publish.PublishGateError, match="--skip-tests"):
publish.load_gate(tmp_path, "1.2.3")
def test_load_gate_returns_certified_wheels(tmp_path):
report_dir = tmp_path / "build" / "release" / "reports" / "1.2.3"
report_dir.mkdir(parents=True)
wheel_dir = tmp_path / "SDK" / "1.2.3"
wheel_dir.mkdir(parents=True)
wheel = wheel_dir / "orisys-1.2.3-cp310-cp310-win_amd64.whl"
wheel.write_bytes(b"wheel")
(report_dir / "preflight.json").write_text(
json.dumps(_report_payload("1.2.3", [wheel])), encoding="utf-8"
)
gate = publish.load_gate(tmp_path, "1.2.3")
assert [record.name for record in gate.wheels] == [wheel.name]
assert gate.wheels[0].sha256 == publish.sha256_file(wheel)
assert gate.git_commit == "a" * 40
# --- github -----------------------------------------------------------------
def test_github_steps_shape(tmp_path):
assets = [_wheel(tmp_path, f"orisys-1.2.3-cp3n{n}-cp3n{n}-win_amd64.whl") for n in (0, 1)]
steps = github.build_github_steps(
version="1.2.3",
branch="main",
tag="v1.2.3",
assets=assets,
notes_file=None,
)
by_description = {step.description: step.argv for step in steps}
assert by_description["create tag"] == ["git", "tag", "v1.2.3"]
assert by_description["push branch"] == ["git", "push", "origin", "main"]
assert by_description["push tag"] == ["git", "push", "origin", "v1.2.3"]
release_argv = by_description["create GitHub Release with assets"]
assert release_argv[:2] == ["gh", "release"]
assert "v1.2.3" in release_argv
assert "--verify-tag" in release_argv
assert "--latest" in release_argv
assert release_argv[-2:] == [str(asset) for asset in assets]
def test_github_steps_include_notes_file(tmp_path):
notes = tmp_path / "notes.md"
notes.write_text("hello", encoding="utf-8")
steps = github.build_github_steps(
version="1.2.3", branch="main", tag="v1.2.3", assets=[], notes_file=notes
)
release_argv = next(
step.argv for step in steps if step.description.startswith("create GitHub Release")
)
assert "--notes-file" in release_argv
assert str(notes) in release_argv
def test_release_notes_list_every_wheel():
notes = github.build_release_notes(
"1.2.3", [Path("a.whl"), Path("b.whl")], "c" * 40
)
assert "a.whl" in notes and "b.whl" in notes
assert "cccccccccccc" in notes
def test_manifest_scope_excludes_internal_docs():
"""The public repo must not receive our internal process documents."""
entry = github.manifest_entry()
docs_entry = next(item for item in entry["sync"] if item["from"] == "docs")
assert set(docs_entry["include"]) == {"get_started.md", "api.md"}
assert "publishing.md" not in docs_entry["include"]
assert "pypi_readme.md" not in docs_entry["include"]
def test_manifest_scope_covers_examples_and_sensor_json_only():
entry = github.manifest_entry()
examples_entry = next(item for item in entry["sync"] if item["from"] == "examples")
# Whole tree (no include list), with caches excluded.
assert "include" not in examples_entry
assert set(examples_entry["exclude"]) >= {"__pycache__", "*.pyc"}
sensors_entry = next(item for item in entry["sync"] if item["from"] == "sensors")
assert sensors_entry["include"]
assert all(
name.endswith(".json") for name in sensors_entry["include"]
), "binary sensor assets belong in the wheel, not the public repo"
def test_manifest_checks_use_the_python_placeholder():
entry = github.manifest_entry()
for command in entry["checks"]:
assert command.startswith("{python} "), command
# --- pypi -------------------------------------------------------------------
def test_twine_upload_argv_targets_the_repository():
argv = pypi.twine_upload_argv([Path("a.whl")], "testpypi")
assert argv[:3] == [sys.executable, "-m", "twine"]
assert "--repository" in argv and "testpypi" in argv
assert argv[-1] == "a.whl"
def test_twine_upload_argv_defaults_to_pypi():
argv = pypi.twine_upload_argv([Path("a.whl")], None)
assert "--repository" not in argv
def test_credentials_hint_reports_missing(monkeypatch, tmp_path):
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
monkeypatch.delenv("TWINE_USERNAME", raising=False)
monkeypatch.setattr(pypi.Path, "home", lambda: tmp_path)
hint = pypi.credentials_hint()
assert hint is not None
assert "TWINE_PASSWORD" in hint
def test_credentials_hint_accepts_env(monkeypatch):
monkeypatch.setenv("TWINE_USERNAME", "__token__")
monkeypatch.setenv("TWINE_PASSWORD", "pypi-secret")
assert pypi.credentials_hint() is None
def test_credentials_hint_accepts_pypirc(monkeypatch, tmp_path):
monkeypatch.delenv("TWINE_PASSWORD", raising=False)
monkeypatch.delenv("TWINE_USERNAME", raising=False)
(tmp_path / ".pypirc").write_text("[pypi]\n", encoding="utf-8")
monkeypatch.setattr(pypi.Path, "home", lambda: tmp_path)
assert pypi.credentials_hint() is None
def test_index_state_parses_versions(monkeypatch):
class Response:
def __init__(self, payload):
self._payload = json.dumps(payload).encode()
def read(self, *args):
return self._payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(
pypi.urllib.request,
"urlopen",
lambda *a, **k: Response({"releases": {"1.2.3": [{"filename": "a"}], "0.0.0": []}}),
)
state = pypi.index_state("pypi")
assert state.known
assert state.versions == ["1.2.3"]
def test_index_state_reports_lookup_failure(monkeypatch):
def _raise(*args, **kwargs):
raise urllib.error.URLError("offline")
monkeypatch.setattr(pypi.urllib.request, "urlopen", _raise)
state = pypi.index_state("pypi")
assert not state.known
assert "offline" in state.error
def test_index_files_returns_none_when_version_absent(monkeypatch):
class Response:
def read(self, *args):
return json.dumps({"releases": {}}).encode()
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(pypi.urllib.request, "urlopen", lambda *a, **k: Response())
assert pypi.index_files("pypi", "1.2.3") is None
def test_index_json_urls_cover_both_indexes():
assert pypi.INDEX_JSON["testpypi"].startswith("https://test.pypi.org/")
assert pypi.INDEX_JSON["pypi"].startswith("https://pypi.org/")
+16 -16
View File
@@ -5,21 +5,21 @@
"name": "orisys-sdk-release", "name": "orisys-sdk-release",
"path": "../orisys-sdk-release", "path": "../orisys-sdk-release",
"sync": [ "sync": [
{"from": "docs", "to": "docs"}, {"from": "docs", "to": "docs", "include": ["get_started.md", "api.md"]},
{"from": "examples", "to": "examples", "include": [ {"from": "examples", "to": "examples", "exclude": ["__pycache__", "*.pyc", "*.pyo"]},
"README.md", {"from": "config", "to": "config", "include": ["box.json", "finger.json", "mini.json"]},
"basic_pipeline.py", {"from": "sensors", "to": "sensors", "include": [
"mini_pipeline.py", "box/sensor.json",
"finger_pipeline.py", "box/force_calibration.json",
"finger_pipeline_3dview.py", "finger/sensor.json",
"plot_data.py", "finger/manifest.json",
"two_sensor_pipeline.py" "mini/sensor.json"
]}, ]}
{"from": "config", "to": "config", "include": ["box.json", "finger.json", "mini.json"]}
], ],
"checks": [ "checks": [
"python -m py_compile examples/basic_pipeline.py examples/finger_pipeline.py examples/finger_pipeline_3dview.py examples/plot_data.py", "{python} -c \"import pathlib, py_compile; files=sorted(pathlib.Path('examples').rglob('*.py')); [py_compile.compile(str(f), doraise=True) for f in files]; print('compiled %d example module(s)' % len(files))\"",
"python -c \"import pathlib; p=pathlib.Path('.'); hits=[f for f in p.rglob('*') if f.is_file() and 'examples/products' in f.read_text(encoding='utf-8', errors='ignore').replace('\\\\','/')]; print('stale products paths:' if hits else 'no stale products paths'); [print(x) for x in hits]\"" "{python} -c \"import pathlib, sys; bad=[str(p) for p in pathlib.Path('.').rglob('*') if p.is_file() and p.suffix in ('.md','.py') and 'examples/products' in p.read_text(encoding='utf-8', errors='ignore').replace(chr(92),'/')]; print('stale products paths:' if bad else 'no stale products paths'); [print(' ', x) for x in bad]; sys.exit(1 if bad else 0)\"",
"{python} -c \"import pathlib, sys; leaked=[str(p) for p in pathlib.Path('.').rglob('*') if p.name in ('publishing.md','pypi_readme.md')]; print('internal docs leaked into the public repo:', leaked); sys.exit(1 if leaked else 0)\""
] ]
}, },
{ {
@@ -29,7 +29,7 @@
{"from": "config", "to": "config", "include": ["box.json", "mini.json", "dd06-ov-0810.json"]} {"from": "config", "to": "config", "include": ["box.json", "mini.json", "dd06-ov-0810.json"]}
], ],
"checks": [ "checks": [
"python tests/test_smoke.py" "{python} tests/test_smoke.py"
] ]
}, },
{ {
@@ -40,8 +40,8 @@
{"from": "assets/finger", "to": "assets/finger"} {"from": "assets/finger", "to": "assets/finger"}
], ],
"checks": [ "checks": [
"python tests/test_smoke.py", "{python} tests/test_smoke.py",
"python -c \"import pathlib; t=pathlib.Path('README.md').read_text(encoding='utf-8'); print('README still mentions examples/products' if 'examples/products' in t else 'README products path ok')\"" "{python} -c \"import pathlib; t=pathlib.Path('README.md').read_text(encoding='utf-8'); print('README still mentions examples/products' if 'examples/products' in t else 'README products path ok')\""
] ]
} }
] ]
+74 -23
View File
@@ -2,7 +2,9 @@
"""Plan and apply downstream file syncs from tools/downstream_sync_manifest.json. """Plan and apply downstream file syncs from tools/downstream_sync_manifest.json.
The ``apply`` command copies files/directories listed in the manifest. It does The ``apply`` command copies files/directories listed in the manifest. It does
not delete extra downstream files and does not run downstream tests. not delete extra downstream files and does not run downstream tests by default;
``run-checks`` (or ``release github``) runs the manifest's ``checks`` commands
with ``{python}`` replaced by the running interpreter.
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,13 +12,24 @@ from __future__ import annotations
import argparse import argparse
import json import json
import shutil import shutil
import subprocess
import sys import sys
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
MANIFEST_NAME = "downstream_sync_manifest.json" MANIFEST_NAME = "downstream_sync_manifest.json"
@dataclass(frozen=True)
class SyncJob:
"""One source -> destination copy, with optional name patterns to skip."""
source: Path
dest: Path
exclude: tuple[str, ...] = ()
def sdk_root() -> Path: def sdk_root() -> Path:
return Path(__file__).resolve().parent.parent return Path(__file__).resolve().parent.parent
@@ -43,18 +56,19 @@ def selected_downstreams(manifest: dict, target: str | None) -> list[dict]:
return matches return matches
def expand_jobs(root: Path, item: dict) -> list[tuple[Path, Path]]: def expand_jobs(root: Path, item: dict) -> list[SyncJob]:
dest_root = (root / item["path"]).resolve() dest_root = (root / item["path"]).resolve()
jobs: list[tuple[Path, Path]] = [] jobs: list[SyncJob] = []
for entry in item.get("sync", []): for entry in item.get("sync", []):
src_base = root / entry["from"] src_base = root / entry["from"]
dest_base = dest_root / entry["to"] dest_base = dest_root / entry["to"]
exclude = tuple(entry.get("exclude") or ())
includes = entry.get("include") includes = entry.get("include")
if includes: if includes:
for name in includes: for name in includes:
jobs.append((src_base / name, dest_base / name)) jobs.append(SyncJob(src_base / name, dest_base / name, exclude))
else: else:
jobs.append((src_base, dest_base)) jobs.append(SyncJob(src_base, dest_base, exclude))
return jobs return jobs
@@ -68,8 +82,9 @@ def print_plan(root: Path, items: list[dict]) -> None:
jobs = expand_jobs(root, item) jobs = expand_jobs(root, item)
if jobs: if jobs:
print("sync:") print("sync:")
for src, dest in jobs: for job in jobs:
print(f" {src} -> {dest}") note = f" (excluding {', '.join(job.exclude)})" if job.exclude else ""
print(f" {job.source} -> {job.dest}{note}")
else: else:
print("sync: (none)") print("sync: (none)")
checks = item.get("checks") or [] checks = item.get("checks") or []
@@ -94,14 +109,14 @@ def check_paths(root: Path, items: list[dict]) -> int:
else: else:
print(f"MISS downstream root: {dest_root}") print(f"MISS downstream root: {dest_root}")
missing.append(str(dest_root)) missing.append(str(dest_root))
for src, dest in expand_jobs(root, item): for job in expand_jobs(root, item):
src_ok = src.exists() src_ok = job.source.exists()
dest_note = "exists" if dest.exists() else "missing (ok until apply)" dest_note = "exists" if job.dest.exists() else "missing (ok until apply)"
status = "ok " if src_ok else "MISS" status = "ok " if src_ok else "MISS"
print(f"{status} source: {src}") print(f"{status} source: {job.source}")
print(f" dest: {dest} [{dest_note}]") print(f" dest: {job.dest} [{dest_note}]")
if not src_ok: if not src_ok:
missing.append(str(src)) missing.append(str(job.source))
print() print()
if missing: if missing:
print(f"{len(missing)} missing path(s).") print(f"{len(missing)} missing path(s).")
@@ -110,14 +125,16 @@ def check_paths(root: Path, items: list[dict]) -> int:
return 0 return 0
def copy_job(src: Path, dest: Path) -> None: def copy_job(job: SyncJob) -> None:
if not src.exists(): if not job.source.exists():
raise FileNotFoundError(src) raise FileNotFoundError(job.source)
if src.is_dir(): if job.source.is_dir():
shutil.copytree(src, dest, dirs_exist_ok=True) # exclude keeps caches out of the public repo (e.g. __pycache__, *.pyc)
ignore = shutil.ignore_patterns(*job.exclude) if job.exclude else None
shutil.copytree(job.source, job.dest, dirs_exist_ok=True, ignore=ignore)
return return
dest.parent.mkdir(parents=True, exist_ok=True) job.dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest) shutil.copy2(job.source, job.dest)
def apply_sync(root: Path, items: list[dict]) -> int: def apply_sync(root: Path, items: list[dict]) -> int:
@@ -128,16 +145,45 @@ def apply_sync(root: Path, items: list[dict]) -> int:
copied = 0 copied = 0
for item in items: for item in items:
print(f"== {item['name']} ==") print(f"== {item['name']} ==")
for src, dest in expand_jobs(root, item): for job in expand_jobs(root, item):
copy_job(src, dest) copy_job(job)
copied += 1 copied += 1
print(f"copied {src} -> {dest}") print(f"copied {job.source} -> {job.dest}")
print() print()
print(f"copied {copied} sync job(s).") print(f"copied {copied} sync job(s).")
print("checks were not run; execute the listed check commands manually.") print("checks were not run; execute the listed check commands manually.")
return 0 return 0
def run_checks(root: Path, items: list[dict], *, python: str | None = None) -> int:
"""Run each manifest ``checks`` command inside the downstream repo.
``{python}`` in a command is replaced with ``python`` (or the interpreter
given). Returns the number of failed checks.
"""
interpreter = python or sys.executable
failures = 0
for item in items:
dest_root = (root / item["path"]).resolve()
checks = item.get("checks") or []
if not checks:
continue
print(f"== {item['name']} ==")
for command in checks:
resolved = command.replace("{python}", interpreter)
print(f"running: {resolved}")
completed = subprocess.run(
resolved, shell=True, cwd=dest_root, check=False
)
if completed.returncode != 0:
print(f"[FAIL] exit {completed.returncode}: {resolved}")
failures += 1
else:
print("[ok ] check passed")
print()
return failures
def parse_args(argv: list[str]) -> argparse.Namespace: def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@@ -151,6 +197,9 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
apply_cmd = sub.add_parser("apply", help="copy manifest sources to downstreams") apply_cmd = sub.add_parser("apply", help="copy manifest sources to downstreams")
apply_cmd.add_argument("--target", help="limit to one downstream name") apply_cmd.add_argument("--target", help="limit to one downstream name")
run = sub.add_parser("run-checks", help="run the manifest checks in each downstream")
run.add_argument("--target", help="limit to one downstream name")
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -165,6 +214,8 @@ def main(argv: list[str] | None = None) -> int:
return check_paths(root, items) return check_paths(root, items)
if args.command == "apply": if args.command == "apply":
return apply_sync(root, items) return apply_sync(root, items)
if args.command == "run-checks":
return 1 if run_checks(root, items) else 0
raise SystemExit(f"unknown command: {args.command}") raise SystemExit(f"unknown command: {args.command}")