diff --git a/.github/workflows/runtime-snapshots.yml b/.github/workflows/runtime-snapshots.yml index c0c462411c..88c7948c24 100644 --- a/.github/workflows/runtime-snapshots.yml +++ b/.github/workflows/runtime-snapshots.yml @@ -8,6 +8,8 @@ on: - ".github/workflows/runtime-snapshots.yml" - "scripts/runtime_artifact.py" - "scripts/qualify_runtime_artifact.py" + - "tests/test_runtime_snapshot_publication.py" + - "tests/test_update_artifact.py" - "scripts/install*.sh" - "loopx/cli_commands/update*.py" - "loopx/cli_commands/support_control_update*.py" @@ -43,7 +45,7 @@ jobs: - name: Prepare test environment run: python -m pip install -e '.[test]' - name: Qualify update transport and ownership - run: python -m pytest -q tests/test_update_artifact.py tests/test_update_operation.py tests/test_self_update_runtime_activation.py tests/test_archive_installer_commit_response.py + run: python -m pytest -q tests/test_runtime_snapshot_publication.py tests/test_update_artifact.py tests/test_update_operation.py tests/test_self_update_runtime_activation.py tests/test_archive_installer_commit_response.py - name: Build qualified frontend run: python scripts/chat_bundle.py ensure - name: Build immutable runtime and resource inventory @@ -88,12 +90,14 @@ jobs: run: | set -euo pipefail revision="$(python -c 'import json; print(json.load(open("runtime/loopx-runtime.json"))["source_revision"])')" - # One dedicated channel release keeps developer snapshots out of - # product release history and bounded stable-predecessor discovery. - tag=runtime-main + # Keep each immutable snapshot below GitHub's per-release asset cap. + # The existing channel release retains its historical assets and URL. + tag="runtime-main-${revision}" if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release create "$tag" --repo "$GITHUB_REPOSITORY" --target "$revision" --prerelease --title "Latest qualified main runtime" --notes "Mutable channel metadata; revision-addressed archives and manifests are immutable. Stable users continue with PyPI or the stable runtime." + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --target "$revision" --prerelease --latest=false --title "Qualified main runtime ${revision}" --notes "Immutable runtime snapshot. Stable users continue with PyPI or the stable runtime." fi + actual="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${tag}" --jq .sha)" + if [[ "$actual" != "$revision" ]]; then exit 1; fi # A retry reuses immutable bytes. Never clobber an immutable asset. mkdir -p existing for file in runtime/*; do @@ -109,6 +113,9 @@ jobs: # newer main is still building; never publish a non-main revision. relation="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${revision}...main" --jq .status)" if [[ "$relation" != ahead && "$relation" != identical ]]; then exit 1; fi + if ! gh release view runtime-main --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create runtime-main --repo "$GITHUB_REPOSITORY" --target "$revision" --prerelease --latest=false --title "Latest qualified main runtime" --notes "Mutable channel metadata points to immutable revision releases. Stable users continue with PyPI or the stable runtime." + fi # Rerunning an older successful workflow may retain its immutable # assets, but must not move the qualified main channel backwards. current="$(gh api "repos/${GITHUB_REPOSITORY}/commits/runtime-main" --jq .sha)" @@ -117,3 +124,6 @@ jobs: if [[ "$promotion" != ahead && "$promotion" != identical ]]; then exit 1; fi gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/runtime-main" -f sha="$revision" -F force=false >/dev/null gh release upload runtime-main runtime/loopx-runtime.json --repo "$GITHUB_REPOSITORY" --clobber + mkdir -p channel-readback + gh release download runtime-main --repo "$GITHUB_REPOSITORY" --pattern loopx-runtime.json --dir channel-readback --clobber + cmp runtime/loopx-runtime.json channel-readback/loopx-runtime.json diff --git a/docs/guides/installing-loopx.md b/docs/guides/installing-loopx.md index f32463889e..e40caf0c07 100644 --- a/docs/guides/installing-loopx.md +++ b/docs/guides/installing-loopx.md @@ -115,9 +115,24 @@ Release rollout: publish and qualify the stable runtime before deploying the new public bootstrap. The Pages lane checks this dependency and retains the previous deployed site if it is unavailable. `runtime-snapshots.yml` builds main artifacts; `release-artifacts.yml` reuses the release frontend/wheel lane -for stable. Main archives and revision-addressed metadata share one dedicated -`runtime-main` release, keeping product release history and stable-predecessor -discovery intact; only the latest-channel metadata is replaced. Clean +for stable. New main archives and revision-addressed metadata use one prerelease +per revision, `runtime-main-`, so the channel cannot exhaust GitHub's +1,000-assets-per-release limit. `runtime-main/loopx-runtime.json` remains the +latest-channel endpoint; only that metadata is replaced, after all immutable +assets are uploaded and checked. Historical assets in `runtime-main` stay +available, and current clients fall back to their old SHA address only on HTTP +404. Older clients can follow the unchanged `main` endpoint, but must update +before pinning a newly published SHA. Stable release selection still excludes +these prereleases; the existing all-release pagination grows with snapshot +history. No historical assets are deleted or moved by this publisher. + +中文:新 main 快照各自使用 `runtime-main-<完整 SHA>` 预发布,避免单个 release +达到 1,000 个资产上限。最新入口仍为 `runtime-main/loopx-runtime.json`,所有不可变 +资产完成后才更新;旧资产不删除、不迁移。新版客户端仅在新 SHA 地址返回 404 时 +回读旧地址。旧客户端可先升级 main,再固定新发布的 SHA。stable 仍排除这些预发布, +但现有 release 全量分页的开销会随快照历史增长。 + +Clean install/upgrade, package contents, byte size, provenance and phase timings are release evidence. A 20 MiB runtime is an evaluation target, not a waived dependency check or a global p50/p95 promise. diff --git a/loopx/cli_commands/update_artifact.py b/loopx/cli_commands/update_artifact.py index 95a9a07a35..923c400a03 100644 --- a/loopx/cli_commands/update_artifact.py +++ b/loopx/cli_commands/update_artifact.py @@ -125,12 +125,20 @@ def resolve_artifact(repo: str, ref: str) -> dict[str, Any]: elif ref == "main": url = f"https://github.com/{repo}/releases/download/runtime-main/loopx-runtime.json" elif SHA.fullmatch(ref): - url = f"https://github.com/{repo}/releases/download/runtime-main/loopx-runtime-{ref}.json" + url = f"https://github.com/{repo}/releases/download/runtime-main-{ref}/loopx-runtime-{ref}.json" elif re.fullmatch(r"v[0-9][A-Za-z0-9.+-]*", ref): url = f"https://github.com/{repo}/releases/download/{ref}/loopx-runtime.json" else: raise ValueError("This ref has no runtime feed; explicitly use --source to build it") - artifact = validate_manifest(read_json(url), revision=ref if SHA.fullmatch(ref) else None) + try: + metadata = read_json(url) + except ValueError as exc: + # Older snapshots stay in the original release. Only a missing new + # address permits that lookup; corrupt metadata/auth/TLS failures stop. + if not (SHA.fullmatch(ref) and isinstance(exc.__cause__, HTTPError) and exc.__cause__.code == 404): + raise + metadata = read_json(f"https://github.com/{repo}/releases/download/runtime-main/loopx-runtime-{ref}.json") + artifact = validate_manifest(metadata, revision=ref if SHA.fullmatch(ref) else None) if ref in {"main", "stable"} and artifact.get("channel") != ref: raise ValueError("Runtime artifact belongs to another channel") return artifact diff --git a/scripts/runtime_artifact.py b/scripts/runtime_artifact.py index 5b4cac2997..9c4316c700 100644 --- a/scripts/runtime_artifact.py +++ b/scripts/runtime_artifact.py @@ -103,7 +103,7 @@ def build(root: Path, output: Path, *, repository: str, channel: str) -> dict: "sha256": hashlib.sha256(identity).hexdigest()}) if git.wait() != 0 or not version: raise ValueError("Git runtime archive failed") - tag = "runtime-main" if channel == "main" else "v" + version + tag = f"runtime-main-{revision}" if channel == "main" else "v" + version archive = output / filename result = validate_manifest({"schema_version": RUNTIME_ARTIFACT_SCHEMA_VERSION, "source_revision": revision, "package_version": version, "channel": channel, "size": archive.stat().st_size, diff --git a/tests/presentation/test_chat_bundle.py b/tests/presentation/test_chat_bundle.py index 339fd86265..66434926ff 100644 --- a/tests/presentation/test_chat_bundle.py +++ b/tests/presentation/test_chat_bundle.py @@ -256,6 +256,8 @@ def release(tag, *, draft=False, prerelease=False): } releases = [ + [release(f"runtime-main-{index:040x}", prerelease=True) for index in range(100)], + [release(f"runtime-main-{index:040x}", prerelease=True) for index in range(100, 200)], [ release("v1.0.4"), release("v1.0.5"), diff --git a/tests/test_runtime_snapshot_publication.py b/tests/test_runtime_snapshot_publication.py new file mode 100644 index 0000000000..ba01434651 --- /dev/null +++ b/tests/test_runtime_snapshot_publication.py @@ -0,0 +1,265 @@ +"""Exercise the Linux publisher against a bounded, filesystem-backed GitHub peer.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +REVISION = "a" * 40 +PREVIOUS = "b" * 40 +SNAPSHOT = f"runtime-main-{REVISION}" +WORKFLOW = yaml.safe_load((ROOT / ".github/workflows/runtime-snapshots.yml").read_text(encoding="utf-8")) +PUBLISH = next(step["run"] for step in WORKFLOW["jobs"]["publish"]["steps"] if "run" in step) +pytestmark = pytest.mark.skipif(os.name == "nt", reason="Publisher executes in Ubuntu Bash") + +# Only the remote GitHub boundary is replaced. Bash, files, cmp and retry are real. +PEER = r'''import json +import os +from pathlib import Path +import sys +state_path = Path(os.environ["FAKE_GITHUB_STATE"]) +state = json.loads(state_path.read_text(encoding="utf-8")) +args = sys.argv[1:] +state["calls"].append(args) +status = 0 +try: + if args[:2] == ["release", "view"]: + status = int(args[2] not in state["releases"] or state.get("view_failure") == args[2]) + elif args[:2] == ["release", "create"]: + assert "--prerelease" in args and "--latest=false" in args + if args[2] in state["releases"]: + status = 1 + else: + state["releases"][args[2]] = {"sha": args[args.index("--target") + 1], "assets": {}} + elif args[:2] == ["release", "download"]: + assets = state["releases"][args[2]]["assets"] + name = args[args.index("--pattern") + 1] + if name not in assets: + status = 1 + else: + (Path(args[args.index("--dir") + 1]) / name).write_bytes(bytes.fromhex(assets[name])) + elif args[:2] == ["release", "upload"]: + assets = state["releases"][args[2]]["assets"] + file = Path(args[3]) + pointer = file.name == "loopx-runtime.json" + # gh --clobber deletes the old asset before POST, not atomically. + if "--clobber" in args: + assets.pop(file.name, None) + if state.get("fail_upload") == file.name or (pointer and state.get("pointer_failure") == "after_delete"): + status = 1 + elif file.name in assets and "--clobber" not in args: + status = 1 + elif file.name not in assets and len(assets) >= 1000: + print("file_count limited to 1000 assets per release", file=sys.stderr) + status = 1 + else: + assets[file.name] = file.read_bytes().hex() + if pointer and state.get("pointer_failure") == "after_write": + status = 1 + elif args[0] == "api": + endpoint = next(arg for arg in args if arg.startswith("repos/")) + if "/compare/" in endpoint: + print(state["main_relation"] if endpoint.endswith("...main") else state["promotion"]) + elif "/commits/" in endpoint: + print(state["releases"][endpoint.rsplit("/", 1)[1]]["sha"]) + elif "/git/refs/tags/" in endpoint: + assert "PATCH" in args and "force=false" in args + state["releases"]["runtime-main"]["sha"] = next(arg[4:] for arg in args if arg.startswith("sha=")) + else: + raise AssertionError(args) + else: + raise AssertionError(args) +finally: + state_path.write_text(json.dumps(state), encoding="utf-8") +sys.exit(status) +''' + + +@pytest.fixture +def publication(tmp_path): + runtime = tmp_path / "runtime" + runtime.mkdir() + for stem, suffix in (("loopx-runtime", ".tar.gz"), ("loopx-resources", ".tar.gz"), + ("runtime-inventory", ".json"), ("qualification", ".json")): + (runtime / f"{stem}-{REVISION}{suffix}").write_bytes(f"{stem}:immutable".encode()) + manifest = json.dumps({"source_revision": REVISION, "urls": [ + f"https://github.com/example/loopx/releases/download/{SNAPSHOT}/loopx-runtime-{REVISION}.tar.gz"]}) + (runtime / "loopx-runtime.json").write_text(manifest, encoding="utf-8") + (runtime / f"loopx-runtime-{REVISION}.json").write_text(manifest, encoding="utf-8") + assets = {f"historical-{i}": b"historical".hex() for i in range(999)} + assets["loopx-runtime.json"] = json.dumps({"source_revision": PREVIOUS}).encode().hex() + state = {"calls": [], "main_relation": "identical", "promotion": "ahead", + "releases": {"runtime-main": {"sha": PREVIOUS, "assets": assets}}} + (tmp_path / "github.json").write_text(json.dumps(state), encoding="utf-8") + (tmp_path / "github.py").write_text(PEER, encoding="utf-8") + return tmp_path + + +def read_state(root): + return json.loads((root / "github.json").read_text(encoding="utf-8")) + + +def write_state(root, state): + (root / "github.json").write_text(json.dumps(state), encoding="utf-8") + + +def publish(root): + # The production workflow uses python from setup-python. Bind the same + # interpreter as pytest without requiring a particular host PATH alias. + functions = f"gh() {{ {shlex.quote(sys.executable)} github.py \"$@\"; }}\n" + functions += f"python() {{ {shlex.quote(sys.executable)} \"$@\"; }}\n" + return subprocess.run(["bash", "-c", functions + PUBLISH], cwd=root, + env={**os.environ, "GITHUB_REPOSITORY": "example/loopx", "FAKE_GITHUB_STATE": str(root / "github.json")}, + capture_output=True, text=True, encoding="utf-8", timeout=30, check=False) + + +def test_full_legacy_release_keeps_history_and_publishes_then_retries(publication): + history = read_state(publication)["releases"]["runtime-main"]["assets"].copy() + result = publish(publication) + assert result.returncode == 0, result.stderr + state = read_state(publication) + assert len(state["releases"]["runtime-main"]["assets"]) == 1000 + assert len(state["releases"][SNAPSHOT]["assets"]) == 5 + for name, content in history.items(): + if name != "loopx-runtime.json": + assert state["releases"]["runtime-main"]["assets"][name] == content + pointer = bytes.fromhex(state["releases"]["runtime-main"]["assets"]["loopx-runtime.json"]) + assert json.loads(pointer)["source_revision"] == REVISION + uploads = [call for call in state["calls"] if call[:2] == ["release", "upload"]] + assert [call[2] for call in uploads] == [SNAPSHOT] * 5 + ["runtime-main"] + assert all("--clobber" not in call for call in uploads[:-1]) + state["calls"] = [] + state["promotion"] = "identical" + write_state(publication, state) + result = publish(publication) + assert result.returncode == 0, result.stderr + uploads = [call for call in read_state(publication)["calls"] if call[:2] == ["release", "upload"]] + assert len(uploads) == 1 and uploads[0][2] == "runtime-main" + + +def test_partial_publication_resumes_without_advancing_the_pointer(publication): + state = read_state(publication) + state["fail_upload"] = f"qualification-{REVISION}.json" + write_state(publication, state) + assert publish(publication).returncode != 0 + state = read_state(publication) + assert state["releases"]["runtime-main"]["sha"] == PREVIOUS + assert 0 < len(state["releases"][SNAPSHOT]["assets"]) < 5 + del state["fail_upload"] + write_state(publication, state) + result = publish(publication) + assert result.returncode == 0, result.stderr + assert len(read_state(publication)["releases"][SNAPSHOT]["assets"]) == 5 + + +@pytest.mark.parametrize("collision", ["bytes", "revision"]) +def test_immutable_collision_cannot_advance_channel(publication, collision): + state = read_state(publication) + state["releases"][SNAPSHOT] = {"sha": PREVIOUS if collision == "revision" else REVISION, + "assets": {} if collision == "revision" else {f"loopx-resources-{REVISION}.tar.gz": b"different".hex()}} + write_state(publication, state) + assert publish(publication).returncode != 0 + state = read_state(publication) + assert state["releases"]["runtime-main"]["sha"] == PREVIOUS + assert not any("--clobber" in call for call in state["calls"]) + + +@pytest.mark.parametrize("main_relation,promotion,success", [ + ("ahead", "behind", True), ("diverged", "ahead", False), ("identical", "diverged", False), +]) +def test_lineage_and_older_runs_never_replace_channel(publication, main_relation, promotion, success): + state = read_state(publication) + state.update(main_relation=main_relation, promotion=promotion) + write_state(publication, state) + assert (publish(publication).returncode == 0) == success + state = read_state(publication) + assert state["releases"]["runtime-main"]["sha"] == PREVIOUS + assert not any("--clobber" in call for call in state["calls"]) + + +@pytest.mark.parametrize("failure", ["after_delete", "after_write"]) +def test_pointer_retry_recovers_deleted_or_unacknowledged_upload(publication, failure): + state = read_state(publication) + state["pointer_failure"] = failure + write_state(publication, state) + assert publish(publication).returncode != 0 + state = read_state(publication) + channel = state["releases"]["runtime-main"] + assert channel["sha"] == REVISION + assert ("loopx-runtime.json" in channel["assets"]) == (failure == "after_write") + assert len(state["releases"][SNAPSHOT]["assets"]) == 5 + del state["pointer_failure"] + state["promotion"] = "identical" + write_state(publication, state) + result = publish(publication) + assert result.returncode == 0, result.stderr + channel = read_state(publication)["releases"]["runtime-main"] + assert len(channel["assets"]) == 1000 + assert bytes.fromhex(channel["assets"]["loopx-runtime.json"]) == (publication / "runtime/loopx-runtime.json").read_bytes() + + +def test_first_snapshot_bootstraps_the_channel_after_immutable_uploads(publication): + state = read_state(publication) + state["releases"] = {} + write_state(publication, state) + result = publish(publication) + assert result.returncode == 0, result.stderr + state = read_state(publication) + assert len(state["releases"]["runtime-main"]["assets"]) == 1 + assert len(state["releases"][SNAPSHOT]["assets"]) == 5 + + +@pytest.mark.parametrize("tag", ["runtime-main", SNAPSHOT]) +def test_failed_view_does_not_replace_an_existing_release(publication, tag): + state = read_state(publication) + state["releases"][SNAPSHOT] = {"sha": REVISION, "assets": {}} + state["view_failure"] = tag + write_state(publication, state) + history = state["releases"]["runtime-main"].copy() + assert publish(publication).returncode != 0 + state = read_state(publication) + assert state["releases"]["runtime-main"] == history + assert not any("--clobber" in call for call in state["calls"]) + + +@pytest.mark.parametrize("channel", ["main", "stable"]) +def test_builder_freezes_correct_archive_url_before_publication(tmp_path, monkeypatch, channel): + from scripts import runtime_artifact as builder + + root = tmp_path / "source" + root.mkdir() + for name in builder.PATHS: + path = root / name + if name in {"loopx", "scripts", "skills", "docs", "man", "examples", "apps/presentation", ".github"}: + path.mkdir(parents=True, exist_ok=True) + (path / "fixture.txt").write_text("public fixture", encoding="utf-8") + else: + path.write_text("public fixture", encoding="utf-8") + (root / "pyproject.toml").write_text('[project]\nversion = "1.2.3"\n', encoding="utf-8") + bundle = root / "loopx/web/chat" + bundle.mkdir(parents=True) + (bundle / "index.html").write_text("

fixture

", encoding="utf-8") + (bundle / "bundle-manifest.json").write_text("{}", encoding="utf-8") + env = {**os.environ, "GIT_AUTHOR_NAME": "Fixture Author", "GIT_COMMITTER_NAME": "Fixture Author", + "GIT_AUTHOR_EMAIL": "fixture@example.invalid", "GIT_COMMITTER_EMAIL": "fixture@example.invalid"} + for args in (["init", "--quiet"], ["add", "--", *builder.PATHS], ["commit", "--quiet", "-m", "Synthetic runtime"]): + subprocess.run(["git", *args], cwd=root, env=env, check=True, capture_output=True, timeout=15) + revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True, encoding="utf-8").strip() + # Frontend qualification is independent; archive and descriptor creation + # still execute through the real committed-source builder below. + monkeypatch.setattr(builder, "validate_bundle", lambda *_a, **_kw: { + "source_revision": revision, "source_files": {}, "files": {"index.html": "fixture"}}) + output = tmp_path / "runtime" + descriptor = builder.build(root, output, repository="example/loopx", channel=channel) + tag = f"runtime-main-{revision}" if channel == "main" else "v1.2.3" + assert descriptor["urls"] == [f"https://github.com/example/loopx/releases/download/{tag}/loopx-runtime-{revision}.tar.gz"] + assert json.loads((output / "loopx-runtime.json").read_text(encoding="utf-8")) == descriptor + assert (output / f"loopx-runtime-{revision}.tar.gz").stat().st_size == descriptor["size"] diff --git a/tests/test_update_artifact.py b/tests/test_update_artifact.py index bc99905443..a6b506fd50 100644 --- a/tests/test_update_artifact.py +++ b/tests/test_update_artifact.py @@ -103,7 +103,7 @@ def test_redirects_cannot_weaken_source_tls(): assert "require HTTPS" in str(caught.value.__cause__) -def test_pinned_runtime_metadata_is_revision_addressed_within_the_channel_release(monkeypatch): +def test_pinned_runtime_metadata_is_revision_addressed_in_its_own_release(monkeypatch): body = b"runtime" descriptor = manifest(body, "https://publisher.invalid/runtime.tar.gz") requests = [] @@ -112,7 +112,74 @@ def metadata(url): return descriptor monkeypatch.setattr(update, "read_json", metadata) assert update.resolve_artifact("example/loopx", "a" * 40)["source_revision"] == "a" * 40 - assert requests == ["https://github.com/example/loopx/releases/download/runtime-main/loopx-runtime-" + "a" * 40 + ".json"] + assert requests == ["https://github.com/example/loopx/releases/download/runtime-main-" + "a" * 40 + "/loopx-runtime-" + "a" * 40 + ".json"] + + +@pytest.mark.parametrize("status", [404, 403, 429, 500]) +def test_pinned_metadata_falls_back_only_when_the_new_address_is_missing(monkeypatch, status): + from urllib.error import HTTPError + descriptor = manifest(b"runtime", "https://publisher.invalid/runtime.tar.gz") + calls = [] + def metadata(url): + calls.append(url) + if len(calls) == 1: + try: + raise HTTPError(url, status, "synthetic response", None, None) + except HTTPError as cause: + raise ValueError("Update metadata unavailable") from cause + return descriptor + monkeypatch.setattr(update, "read_json", metadata) + if status == 404: + assert update.resolve_artifact("example/loopx", "a" * 40) == descriptor + assert calls[1] == "https://github.com/example/loopx/releases/download/runtime-main/loopx-runtime-" + "a" * 40 + ".json" + else: + with pytest.raises(ValueError, match="metadata unavailable"): + update.resolve_artifact("example/loopx", "a" * 40) + assert len(calls) == 1 + + +def test_legacy_fallback_still_rejects_another_revision(monkeypatch): + from urllib.error import HTTPError + calls = [] + def metadata(url): + calls.append(url) + if len(calls) == 1: + raise ValueError("missing") from HTTPError(url, 404, "missing", None, None) + return {**manifest(b"runtime", "https://publisher.invalid/runtime.tar.gz"), "source_revision": "b" * 40} + monkeypatch.setattr(update, "read_json", metadata) + with pytest.raises(ValueError, match="another revision"): + update.resolve_artifact("example/loopx", "a" * 40) + assert len(calls) == 2 + + +@pytest.mark.parametrize("failure", ["invalid", "revision", "transport"]) +def test_pinned_metadata_does_not_fallback_from_invalid_or_unverified_sources(monkeypatch, failure): + descriptor = manifest(b"runtime", "https://publisher.invalid/runtime.tar.gz") + calls = [] + def metadata(url): + calls.append(url) + if failure == "transport": + raise ValueError("Update metadata unavailable") from OSError("synthetic TLS failure") + if failure == "invalid": + return {} + return {**descriptor, "source_revision": "b" * 40} + monkeypatch.setattr(update, "read_json", metadata) + with pytest.raises(ValueError): + update.resolve_artifact("example/loopx", "a" * 40) + assert len(calls) == 1 + + +@pytest.mark.parametrize("ref,channel,suffix", [ + ("main", "main", "download/runtime-main/loopx-runtime.json"), + ("stable", "stable", "latest/download/loopx-runtime.json"), + ("v1.2.3", "stable", "download/v1.2.3/loopx-runtime.json"), +]) +def test_channel_and_product_release_addresses_remain_unchanged(monkeypatch, ref, channel, suffix): + descriptor = {**manifest(b"runtime", "https://publisher.invalid/runtime.tar.gz"), "channel": channel} + calls = [] + monkeypatch.setattr(update, "read_json", lambda url: calls.append(url) or descriptor) + assert update.resolve_artifact("example/loopx", ref) == descriptor + assert calls == ["https://github.com/example/loopx/releases/" + suffix] @pytest.mark.parametrize("relation", ["ahead", "behind", "diverged"])