diff --git a/vs-code-terraphim-it/scripts/publish_release.py b/vs-code-terraphim-it/scripts/publish_release.py index 287994da4..fc3ac81a7 100644 --- a/vs-code-terraphim-it/scripts/publish_release.py +++ b/vs-code-terraphim-it/scripts/publish_release.py @@ -2,12 +2,14 @@ """Publish qualified VSIX bytes through a resumable draft; never replace assets or tags.""" import argparse import hashlib +from itertools import islice import json from pathlib import Path import re import subprocess import sys import tempfile +import time from urllib.parse import urlparse import zipfile from xml.etree.ElementTree import ParseError @@ -17,6 +19,7 @@ REPO_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*") SHA_RE = re.compile(r"[0-9a-f]{40}") +RELEASE_LOOKUP_DELAYS = (0, 2, 2, 2, 2) def digest(path): @@ -99,6 +102,25 @@ def release(repo, tag): return select_release(pages, tag) +def recover_visible_release(snapshots): + """Consume at most five reads; only an absent draft permits another read.""" + for current in islice(snapshots, len(RELEASE_LOOKUP_DELAYS)): + if current is not None: + return current + raise ValueError("Created draft is not yet readable; rerun the same qualified artifact") + + +def recovered_release(repo, tag): + """Allow eight seconds of backoff for creation/listing consistency.""" + def snapshots(): + for delay in RELEASE_LOOKUP_DELAYS: + if delay: + time.sleep(delay) + yield release(repo, tag) + + return recover_visible_release(snapshots()) + + def verify_remote_tag(repo, tag, source): value = api(f"repos/{repo}/git/ref/tags/{tag}")["object"] for _ in range(5): @@ -167,9 +189,7 @@ def publish(repo, tag, files, source): "Registry availability is verified separately.\n") gh("release", "create", tag, "--repo", f"github.com/{repo}", "--verify-tag", "--draft", "--prerelease", "--latest=false", "--title", tag, "--notes-file", str(notes)) - current = release(repo, tag) - if current is None: - raise ValueError("Draft release could not be recovered") + current = recovered_release(repo, tag) missing = resume_plan(current, tag, files) # Verify every existing asset before mutating an incomplete draft. for path in files: diff --git a/vs-code-terraphim-it/tests/publish_release_test.py b/vs-code-terraphim-it/tests/publish_release_test.py index 55f715590..4e90946f7 100644 --- a/vs-code-terraphim-it/tests/publish_release_test.py +++ b/vs-code-terraphim-it/tests/publish_release_test.py @@ -117,6 +117,62 @@ def test_draft_lookup_is_exact_and_rejects_duplicate_releases(self): with self.assertRaises(ValueError): publication.select_release([[draft], [draft]], self.tag) + def test_created_draft_readback_is_bounded_and_stops_at_first_visible_release(self): + draft = {"tag_name": self.tag, "draft": True, "prerelease": True, "assets": []} + observed = [] + + def snapshots(values): + for value in values: + observed.append(value) + yield value + + self.assertEqual(publication.recover_visible_release(snapshots([None, None, draft, None])), draft) + self.assertEqual(observed, [None, None, draft]) + observed.clear() + self.assertEqual(publication.recover_visible_release(snapshots([None] * 4 + [draft])), draft) + self.assertEqual(len(observed), 5) + observed.clear() + with self.assertRaisesRegex(ValueError, "same qualified artifact"): + publication.recover_visible_release(snapshots([None] * 5 + [draft])) + self.assertEqual(observed, [None] * 5) + self.assertEqual(len(publication.RELEASE_LOOKUP_DELAYS), 5) + self.assertEqual(sum(publication.RELEASE_LOOKUP_DELAYS), 8) + + def test_visible_mismatch_or_ambiguous_readback_is_never_retried(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + vsix, manifest, sums, _ = self.qualified(root) + original = {p.name: p.read_bytes() for p in (vsix, manifest, sums)} + wrong = {"tag_name": "other", "draft": True, "prerelease": True, "assets": []} + values = iter([wrong, None]) + current = publication.recover_visible_release(values) + with self.assertRaisesRegex(ValueError, "tag or preview"): + publication.resume_plan(current, self.tag, [vsix, manifest, sums]) + self.assertIsNone(next(values)) + self.assertEqual(original, {p.name: p.read_bytes() for p in (vsix, manifest, sums)}) + + def ambiguous_snapshots(): + yield None + draft = {"tag_name": self.tag} + yield publication.select_release([[draft, draft]], self.tag) + + with self.assertRaisesRegex(ValueError, "Ambiguous"): + publication.recover_visible_release(ambiguous_snapshots()) + + def test_readback_policy_executes_in_real_subprocess_without_cloud_calls(self): + with tempfile.TemporaryDirectory() as folder: + fixture = Path(folder) / "readbacks.json" + draft = {"tag_name": self.tag, "draft": True, "prerelease": True, "assets": []} + fixture.write_text(json.dumps([None, draft])) + code = ("import json,sys; sys.path.insert(0,sys.argv[1]); import publish_release; " + "print(json.dumps(publish_release.recover_visible_release(" + "iter(json.load(open(sys.argv[2]))))))") + result = subprocess.run([sys.executable, "-c", code, + str(Path(publication.__file__).parent), str(fixture)], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), draft) + def test_anonymous_download_rejects_credentials_and_overwrite_before_network(self): with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "download"