fix github release

This commit is contained in:
ykcheungab
2026-09-12 17:01:35 +08:00
parent 174bceabc4
commit 94f38bc389
2 changed files with 221 additions and 20 deletions
+88 -19
View File
@@ -113,9 +113,14 @@ def build_github_steps(
tag: str, tag: str,
assets: list[Path], assets: list[Path],
notes_file: Path | None, notes_file: Path | None,
create_tag: bool = True,
title: str | None = None, title: str | None = None,
) -> list[CommandStep]: ) -> list[CommandStep]:
"""The mutating commands, as pure data so they can be asserted in tests.""" """The mutating commands, as pure data so they can be asserted in tests.
``create_tag=False`` is the resume path: the tag already exists (a previous
run pushed it before failing), so only the remaining steps are emitted.
"""
release_argv = [ release_argv = [
"gh", "gh",
"release", "release",
@@ -130,15 +135,34 @@ def build_github_steps(
release_argv += ["--notes-file", str(notes_file)] release_argv += ["--notes-file", str(notes_file)]
release_argv += [str(asset) for asset in assets] release_argv += [str(asset) for asset in assets]
return [ steps: list[CommandStep] = []
CommandStep("create tag", ["git", "tag", tag]), if create_tag:
CommandStep("push branch", ["git", "push", "origin", branch]), steps.append(CommandStep("create tag", ["git", "tag", tag]))
CommandStep("push tag", ["git", "push", "origin", tag]), steps.append(CommandStep("push branch", ["git", "push", "origin", branch]))
CommandStep("create GitHub Release with assets", release_argv), steps.append(CommandStep("push tag", ["git", "push", "origin", tag]))
] steps.append(CommandStep("create GitHub Release with assets", release_argv))
return steps
def _check_gh() -> list[str]: def non_github_remote_problem(repo: Path) -> str | None:
"""Why ``gh`` cannot target this repo, or ``None`` if the remote is fine.
``gh`` resolves its repository from the working directory. Running it from
this checkout instead of the release repo is a real failure mode: the SDK
repo's origin is an internal host, and ``gh`` reports the confusing "none of
the git remotes ... point to a known GitHub host".
"""
remote = _git(repo, "remote", "get-url", "origin", check=False)
if "github.com" in remote:
return None
return (
f"the release repo's origin is {remote!r}, which is not a GitHub host. "
"`gh` resolves the repository from the working directory, so point origin "
"at github.com before publishing a release"
)
def _check_gh(repo: Path) -> list[str]:
"""Problems that would stop the release step, reported before any mutation.""" """Problems that would stop the release step, reported before any mutation."""
problems: list[str] = [] problems: list[str] = []
if shutil.which("gh") is None: if shutil.which("gh") is None:
@@ -147,6 +171,11 @@ def _check_gh() -> list[str]:
"and authenticate (`gh auth login`), or use --skip-release-upload" "and authenticate (`gh auth login`), or use --skip-release-upload"
) )
return problems return problems
remote_problem = non_github_remote_problem(repo)
if remote_problem:
problems.append(remote_problem)
completed = subprocess.run( completed = subprocess.run(
["gh", "auth", "status"], capture_output=True, text=True, check=False ["gh", "auth", "status"], capture_output=True, text=True, check=False
) )
@@ -161,12 +190,25 @@ def _existing_release_tags(repo: Path, version: str) -> list[str]:
return sorted(wanted & tags) return sorted(wanted & tags)
def _release_exists(tag: str) -> bool: def _release_exists(repo: Path, tag: str) -> bool | None:
"""Is there already a GitHub Release for ``tag``?
Returns ``None`` when that cannot be determined (``gh`` unavailable), so the
caller can distinguish "not published yet" from "unknown" instead of treating
a missing tool as proof that nothing was published.
Must run with ``cwd`` set to the release repo: `gh` resolves the target
repository from the working directory, and this checkout's own remote is an
internal host.
"""
if shutil.which("gh") is None:
return None
completed = subprocess.run( completed = subprocess.run(
["gh", "release", "view", tag, "--json", "tagName"], ["gh", "release", "view", tag, "--json", "tagName"],
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
cwd=str(repo),
) )
return completed.returncode == 0 return completed.returncode == 0
@@ -244,23 +286,37 @@ def publish(options: Options) -> int:
tag = f"v{version}" tag = f"v{version}"
existing = _existing_release_tags(repo, version) existing = _existing_release_tags(repo, version)
# A tag that exists without a GitHub Release means a previous run got as far
# as pushing the tag and then failed (or the upload was left manual). That is
# exactly the resumable state, so continue instead of refusing.
create_tag = not existing
if existing: if existing:
print( print(
f"[FAIL] tag(s) {existing} already exist in {repo}; " f"[warn] tag(s) {existing} already exist in {repo}; skipping tag creation "
f"{version} looks already released" "and continuing to the release upload"
) )
release_state = _release_exists(repo, tag)
if release_state is True:
print(f"[FAIL] GitHub Release {tag} already exists; nothing to publish")
return 1 return 1
if shutil.which("gh") and _release_exists(tag): if release_state is None:
print(f"[FAIL] GitHub Release {tag} already exists") print(
return 1 "[warn] cannot tell whether a GitHub Release for "
print(f"[ok ] no existing tag or release for {tag}") f"{tag} already exists (`gh` not installed); --apply will stop at the gh check"
)
if create_tag:
print(f"[ok ] tag {tag} does not exist in {repo}")
elif create_tag:
print(f"[ok ] no existing tag or GitHub Release for {tag}")
else:
print(f"[ok ] no GitHub Release for {tag} yet")
# A dry run is a preview of what would happen, so missing tooling is a warning # 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. # there and a hard stop only when we are about to mutate something.
if options.skip_release_upload: if options.skip_release_upload:
print("[warn] --skip-release-upload: the GitHub Release will be left to you") print("[warn] --skip-release-upload: the GitHub Release will be left to you")
else: else:
gh_problems = _check_gh() gh_problems = _check_gh(repo)
if gh_problems: if gh_problems:
for problem in gh_problems: for problem in gh_problems:
print(f"[{'FAIL' if options.apply else 'warn'}] {problem}") print(f"[{'FAIL' if options.apply else 'warn'}] {problem}")
@@ -280,13 +336,21 @@ def publish(options: Options) -> int:
assets = [asset_dir / wheel.name for wheel in wheels] assets = [asset_dir / wheel.name for wheel in wheels]
notes_file = options.notes_file notes_file = options.notes_file
steps = build_github_steps( steps = build_github_steps(
version=version, branch=branch, tag=tag, assets=assets, notes_file=notes_file version=version,
branch=branch,
tag=tag,
assets=assets,
notes_file=notes_file,
create_tag=create_tag,
) )
print() print()
print(f"[plan] sync {len(manifest_entry().get('sync', []))} entry(ies) into {repo}") 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] stage {len(wheels)} wheel(s) into {asset_dir}")
print(f"[plan] commit {repo} and tag {tag}") if create_tag:
print(f"[plan] commit {repo} and create tag {tag}")
else:
print(f"[plan] commit {repo} (tag {tag} already exists)")
for step in steps: for step in steps:
manual = options.skip_release_upload and step.description.startswith( manual = options.skip_release_upload and step.description.startswith(
"create GitHub Release" "create GitHub Release"
@@ -316,7 +380,9 @@ def publish(options: Options) -> int:
else: else:
print("[ok ] nothing new to commit in the release repo") print("[ok ] nothing new to commit in the release repo")
if create_tag:
_git(repo, "tag", tag) _git(repo, "tag", tag)
# Pushing an already-up-to-date ref is a no-op, so resuming is safe.
_git(repo, "push", "origin", branch) _git(repo, "push", "origin", branch)
_git(repo, "push", "origin", tag) _git(repo, "push", "origin", tag)
print(f"[ok ] pushed {branch} and {tag}") print(f"[ok ] pushed {branch} and {tag}")
@@ -336,7 +402,9 @@ def publish(options: Options) -> int:
print("=== release github: repo synced and tagged; release upload left manual ===") print("=== release github: repo synced and tagged; release upload left manual ===")
return 0 return 0
completed = subprocess.run(release_argv, check=False) # `gh` resolves its target repository from the working directory, so it must
# run inside the release repo - this checkout's own origin is an internal host.
completed = subprocess.run(release_argv, check=False, cwd=str(repo))
if completed.returncode != 0: if completed.returncode != 0:
print(f"[FAIL] gh release create exited {completed.returncode}") print(f"[FAIL] gh release create exited {completed.returncode}")
return 1 return 1
@@ -348,6 +416,7 @@ def publish(options: Options) -> int:
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
cwd=str(repo),
) )
if completed.returncode != 0: if completed.returncode != 0:
print("[FAIL] could not read back the release to verify assets") print("[FAIL] could not read back the release to verify assets")
+132
View File
@@ -8,6 +8,7 @@ executes after the gate passes.
from __future__ import annotations from __future__ import annotations
import json import json
import subprocess
import sys import sys
import urllib.error import urllib.error
from pathlib import Path from pathlib import Path
@@ -214,6 +215,137 @@ def test_manifest_scope_covers_examples_and_sensor_json_only():
), "binary sensor assets belong in the wheel, not the public repo" ), "binary sensor assets belong in the wheel, not the public repo"
def test_github_steps_resume_skips_tag_creation(tmp_path):
"""A tag pushed by an earlier failed run must not block the release upload."""
assets = [_wheel(tmp_path, "orisys-1.2.3-cp310-cp310-win_amd64.whl")]
steps = github.build_github_steps(
version="1.2.3",
branch="main",
tag="v1.2.3",
assets=assets,
notes_file=None,
create_tag=False,
)
descriptions = [step.description for step in steps]
assert "create tag" not in descriptions
assert descriptions[-1] == "create GitHub Release with assets"
# The pushes stay: pushing an up-to-date ref is a safe no-op.
assert "push branch" in descriptions and "push tag" in descriptions
def test_non_github_remote_is_reported(tmp_path):
repo = tmp_path / "release"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "remote", "add", "origin", "ssh://git@internal.example:24928/orisys-tech/orisys-sdk.git"],
cwd=repo,
check=True,
capture_output=True,
)
problem = github.non_github_remote_problem(repo)
assert problem is not None and "not a GitHub host" in problem
subprocess.run(
["git", "remote", "set-url", "origin", "git@github.com:orisys-tech/orisys-sdk.git"],
cwd=repo,
check=True,
capture_output=True,
)
assert github.non_github_remote_problem(repo) is None
def _synthetic_publish_layout(tmp_path: Path, version: str, *, tag_exists: bool):
"""Build a fake SDK checkout + release repo and point the module at them."""
sdk = tmp_path / "sdk"
wheel_dir = sdk / "SDK" / version
wheel_dir.mkdir(parents=True)
wheels = [wheel_dir / f"orisys-{version}-cp310-cp310-win_amd64.whl"]
wheels[0].write_bytes(b"wheel")
report_dir = sdk / "build" / "release" / "reports" / version
report_dir.mkdir(parents=True)
(report_dir / "preflight.json").write_text(
json.dumps(_report_payload(version, wheels)), encoding="utf-8"
)
release = tmp_path / "orisys-sdk-release"
release.mkdir()
for command in (
["git", "init", "-q"],
["git", "config", "user.email", "test@example.invalid"],
["git", "config", "user.name", "test"],
["git", "remote", "add", "origin", "git@github.com:orisys-tech/orisys-sdk.git"],
):
subprocess.run(command, cwd=release, check=True, capture_output=True)
(release / "README.md").write_text("x", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=release, check=True, capture_output=True)
subprocess.run(["git", "commit", "-qm", "init"], cwd=release, check=True, capture_output=True)
if tag_exists:
subprocess.run(["git", "tag", f"v{version}"], cwd=release, check=True, capture_output=True)
manifest = sdk / "tools" / "downstream_sync_manifest.json"
manifest.parent.mkdir(parents=True)
manifest.write_text(
json.dumps(
{
"version": 1,
"downstreams": [
{"name": "orisys-sdk-release", "path": "../orisys-sdk-release", "sync": []}
],
}
),
encoding="utf-8",
)
return sdk, release, wheels[0]
def test_publish_dry_run_resumes_when_tag_already_exists(tmp_path, monkeypatch, capsys):
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.3", tag_exists=True)
monkeypatch.setattr(github, "REPO_ROOT", sdk)
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
exit_code = github.publish(
github.Options(version="1.2.3", apply=False, skip_fetch=True)
)
output = capsys.readouterr().out
assert exit_code == 0
assert "already exist" in output and "skipping tag creation" in output
assert "git tag v1.2.3" not in output
assert "gh release create v1.2.3" in output
def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys):
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.4", tag_exists=False)
monkeypatch.setattr(github, "REPO_ROOT", sdk)
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
exit_code = github.publish(
github.Options(version="1.2.4", apply=False, skip_fetch=True)
)
output = capsys.readouterr().out
assert exit_code == 0
assert "git tag v1.2.4" in output
def test_publish_refuses_when_release_already_exists(tmp_path, monkeypatch, capsys):
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.5", tag_exists=True)
monkeypatch.setattr(github, "REPO_ROOT", sdk)
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
monkeypatch.setattr(github, "_check_gh", lambda repo: [])
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: True)
exit_code = github.publish(
github.Options(version="1.2.5", apply=False, skip_fetch=True)
)
assert exit_code == 1
assert "already exists; nothing to publish" in capsys.readouterr().out
def test_manifest_checks_use_the_python_placeholder(): def test_manifest_checks_use_the_python_placeholder():
entry = github.manifest_entry() entry = github.manifest_entry()
for command in entry["checks"]: for command in entry["checks"]: