diff --git a/build/release/github.py b/build/release/github.py index b4544a4..0527755 100644 --- a/build/release/github.py +++ b/build/release/github.py @@ -113,9 +113,14 @@ def build_github_steps( tag: str, assets: list[Path], notes_file: Path | None, + create_tag: bool = True, title: str | None = None, ) -> 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 = [ "gh", "release", @@ -130,15 +135,34 @@ def build_github_steps( 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), - ] + steps: list[CommandStep] = [] + if create_tag: + steps.append(CommandStep("create tag", ["git", "tag", tag])) + steps.append(CommandStep("push branch", ["git", "push", "origin", branch])) + 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: list[str] = [] 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" ) return problems + + remote_problem = non_github_remote_problem(repo) + if remote_problem: + problems.append(remote_problem) + completed = subprocess.run( ["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) -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( ["gh", "release", "view", tag, "--json", "tagName"], capture_output=True, text=True, check=False, + cwd=str(repo), ) return completed.returncode == 0 @@ -244,23 +286,37 @@ def publish(options: Options) -> int: tag = f"v{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: print( - f"[FAIL] tag(s) {existing} already exist in {repo}; " - f"{version} looks already released" + f"[warn] tag(s) {existing} already exist in {repo}; skipping tag creation " + "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 - 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}") + if release_state is None: + print( + "[warn] cannot tell whether a GitHub Release for " + 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 # 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() + gh_problems = _check_gh(repo) if gh_problems: for problem in gh_problems: 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] notes_file = options.notes_file 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(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}") + 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: manual = options.skip_release_upload and step.description.startswith( "create GitHub Release" @@ -316,7 +380,9 @@ def publish(options: Options) -> int: else: print("[ok ] nothing new to commit in the release repo") - _git(repo, "tag", tag) + if create_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", 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 ===") 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: print(f"[FAIL] gh release create exited {completed.returncode}") return 1 @@ -348,6 +416,7 @@ def publish(options: Options) -> int: capture_output=True, text=True, check=False, + cwd=str(repo), ) if completed.returncode != 0: print("[FAIL] could not read back the release to verify assets") diff --git a/tests/test_release_publish.py b/tests/test_release_publish.py index c30799f..4e69c25 100644 --- a/tests/test_release_publish.py +++ b/tests/test_release_publish.py @@ -8,6 +8,7 @@ executes after the gate passes. from __future__ import annotations import json +import subprocess import sys import urllib.error 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" +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(): entry = github.manifest_entry() for command in entry["checks"]: