diff --git a/goal/push/core.py b/goal/push/core.py index cfead9e..d7f81f1 100644 --- a/goal/push/core.py +++ b/goal/push/core.py @@ -466,6 +466,13 @@ def _registry_publication_pending(project_types: List[str]) -> bool: published = get_registry_version(registry, package) if not published or published == current: return False + # The registry's "latest" document is CDN-cached right after an upload, so + # a just-published tree can still look newer. Trust the version-specific + # document before claiming that publication is pending. + from goal.version_validation import exact_version_published + + if exact_version_published(registry, package, current): + return False def _key(version: str) -> tuple: parts = re.findall(r"\d+", version.split("+")[0]) diff --git a/goal/version_validation.py b/goal/version_validation.py index 66acc55..93c96bf 100644 --- a/goal/version_validation.py +++ b/goal/version_validation.py @@ -72,6 +72,27 @@ def get_registry_version(registry: str, package_name: str) -> Optional[str]: return None +def exact_version_published( + registry: str, package_name: str, version: str +) -> bool: + """Return whether an exact version is already visible in the registry. + + The registry "latest" document is CDN-cached for a short window after an + upload, so a tree that was just published can still look newer than the + registry. The version-specific document is the authoritative check for + that window: it answers whether this exact release already exists. + """ + if registry.lower() != "pypi" or not version: + return False + try: + url = f"https://pypi.org/pypi/{package_name}/{version}/json" + with urllib.request.urlopen(url, timeout=10) as response: + data = json.loads(response.read().decode()) + return bool(data.get("info", {}).get("version") == version) + except (urllib.error.URLError, json.JSONDecodeError, KeyError): + return False + + def extract_badge_versions(readme_path: Path) -> List[Tuple[str, str, str]]: """Extract version badges from README.md. diff --git a/project/TICKETS.md b/project/TICKETS.md index 58f962f..f9d3dae 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -423,4 +423,5 @@ This file indexes governance tickets without taking ownership of | **ticket-210** | [`README.md`](./ticket-210/README.md) | - | - | - | - | - | | **ticket-212** | [`README.md`](./ticket-212/README.md) | - | - | - | - | - | | **ticket-211** | [`README.md`](./ticket-211/README.md) | - | - | - | - | - | +| **ticket-213** | [`README.md`](./ticket-213/README.md) | - | - | - | - | - | diff --git a/project/ticket-213/README.md b/project/ticket-213/README.md new file mode 100644 index 0000000..db5e4a3 --- /dev/null +++ b/project/ticket-213/README.md @@ -0,0 +1,26 @@ +# Ticket 213: Confirm exact registry version before treating tree as unpublished + +- **ID**: ticket-213 +- **Owner**: ai-claude +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-25 + +## Goal and scope + +Observed live on heyasa right after the 0.4.0 completion: the registry's +"latest" JSON is CDN-cached, so a second `goal -a` in that window saw the tree +version as newer than the registry, re-entered the completion branch and failed +with the confusing clean force-publish error. The pending check now confirms +the exact version through the version-specific PyPI document before claiming +that publication is pending. + +## Acceptance criteria + +- [ ] AC-01: A CDN-lagged latest with the exact version published reports not pending; a genuinely unpublished version stays pending. +- [ ] AC-02: Existing completion/boundary tests still pass. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-213/intent.json b/project/ticket-213/intent.json new file mode 100644 index 0000000..07090d5 --- /dev/null +++ b/project/ticket-213/intent.json @@ -0,0 +1,90 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-213", + "summary": "Confirm exact registry version before treating tree as unpublished", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "health" + }, + "allowedPaths": [ + "project/ticket-213/**", + "TODO.md", + "project/TICKETS.md", + "goal/push/core.py", + "goal/version_validation.py", + "tests/test_push_workflow_completion.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "086ee8f03ee8e00fa72b296a4f15000d31b76d09", + "targetBranch": "main", + "outcome": "Registry publication pending detection consults the version-specific PyPI document, so CDN lag after an upload no longer resurrects a finished publication or blocks a re-run.", + "nonGoals": [ + "No changes to the publish transports", + "No changes to tag handling", + "No gate or policy changes" + ], + "complexity": "S", + "estimatedMinutes": 25, + "budgets": { + "maxImplementationFiles": 3, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Add exact_version_published() to version_validation and consult it inside _registry_publication_pending before returning pending.", + "components": [ + { + "name": "push-workflow", + "paths": [ + "goal/push/core.py", + "tests/test_push_workflow_completion.py" + ] + }, + { + "name": "registry-validation", + "paths": [ + "goal/version_validation.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Protected revert of the three files; no data migration." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest -q tests/test_push_workflow_completion.py -k exact_version" + ], + "evidence": "receipt:goal213/exact-version" + }, + { + "criterion": "AC-02", + "commands": [ + "python -m pytest -q tests", + "./project/governance-check.sh" + ], + "evidence": "receipt:goal213/regression" + } + ] + } +} diff --git a/tests/test_push_workflow_completion.py b/tests/test_push_workflow_completion.py index b3eecbb..52c7059 100644 --- a/tests/test_push_workflow_completion.py +++ b/tests/test_push_workflow_completion.py @@ -369,6 +369,11 @@ def test_registry_publication_pending_when_tree_ahead_of_registry(monkeypatch, t "goal.version_validation.get_registry_version", lambda registry, package: "2.2.1" if package == "goal" else None, ) + # The exact version is not published yet: the tree genuinely leads. + monkeypatch.setattr( + "goal.version_validation.exact_version_published", + lambda registry, package, version: False, + ) assert push_core._registry_publication_pending(["python"]) is True @@ -392,6 +397,30 @@ def test_registry_publication_not_pending_when_registry_caught_up(monkeypatch, t assert push_core._registry_publication_pending(["python"]) is False +def test_registry_publication_not_pending_when_exact_version_visible(monkeypatch, tmp_path): + """CDN-lagged "latest" must not resurrect a finished publication.""" + from types import SimpleNamespace + + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "heyasa"\nversion = "0.4.0"\n', encoding="utf-8" + ) + sources = [SimpleNamespace(value="0.4.0")] + monkeypatch.setattr( + "goal.cli.version_state.collect_version_sources", lambda: sources + ) + monkeypatch.setattr( + "goal.version_validation.get_registry_version", + lambda registry, package: "0.3.1", # stale cached latest + ) + monkeypatch.setattr( + "goal.version_validation.exact_version_published", + lambda registry, package, version: version == "0.4.0", + ) + + assert push_core._registry_publication_pending(["python"]) is False + + def test_registry_publication_pending_requires_single_tree_version(monkeypatch, tmp_path): from types import SimpleNamespace