diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36509510..ce48bead 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,6 +167,10 @@ jobs: with: name: dist path: dist/ + - uses: actions/download-artifact@v8 + with: + name: release-evidence + path: release-evidence/ - name: Verify GitHub release id: github-release env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c173a8..a7c119b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ consolidated into the next published release. ## [Unreleased] +## [0.23.3] - 2026-07-18 + +### Fixed +- Post-publication verification now downloads the immutable release seal before + creating its receipt and retries bounded PyPI propagation failures. + ## [0.23.2] - 2026-07-18 ### Added @@ -1010,7 +1016,8 @@ See git history for per-commit details on intermediate versions. --- -[Unreleased]: https://github.com/layer1labs/specsmith/compare/v0.23.2...HEAD +[Unreleased]: https://github.com/layer1labs/specsmith/compare/v0.23.3...HEAD +[0.23.3]: https://github.com/layer1labs/specsmith/compare/v0.23.2...v0.23.3 [0.23.2]: https://github.com/layer1labs/specsmith/compare/v0.22.5...v0.23.2 [0.22.5]: https://github.com/layer1labs/specsmith/compare/v0.22.4...v0.22.5 [0.22.4]: https://github.com/layer1labs/specsmith/compare/v0.22.3...v0.22.4 diff --git a/docs/SPECSMITH.yml b/docs/SPECSMITH.yml index 853b6fab..c81fe461 100644 --- a/docs/SPECSMITH.yml +++ b/docs/SPECSMITH.yml @@ -9,10 +9,10 @@ description: | through governance, traceability, and automated compliance checking. type: python -version: 0.23.2 +version: 0.23.3 # Governance schema/tool version that last generated or migrated this project. # This is intentionally distinct from the package/project release version above. -spec_version: 0.23.2 +spec_version: 0.23.3 license: MIT author: Layer1 Labs url: https://github.com/layer1labs/specsmith diff --git a/pyproject.toml b/pyproject.toml index 10868deb..5981a173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "specsmith" -version = "0.23.2" +version = "0.23.3" description = "AEE governance toolkit for AI-assisted development — session preflight gates, multi-agent dispatch, requirements↔test traceability, ESDB persistence, MCP server, and skills for Warp, Cursor, Claude Code, Copilot, Windsurf, Aider, and Zoo Code." readme = "README.md" license = "MIT" diff --git a/scripts/verify_publication.py b/scripts/verify_publication.py index 3627264a..6f54dd32 100644 --- a/scripts/verify_publication.py +++ b/scripts/verify_publication.py @@ -5,6 +5,8 @@ import argparse import hashlib import json +import time +import urllib.error import urllib.request from pathlib import Path from typing import Any @@ -41,6 +43,28 @@ def verify_pypi_files(dist_dir: Path, payload: dict[str, Any]) -> list[dict[str, return verified +def fetch_pypi_payload( + version: str, *, attempts: int = 6, delay_seconds: float = 10 +) -> dict[str, Any]: + """Fetch a release after allowing bounded time for PyPI propagation.""" + if attempts < 1: + raise ValueError("attempts must be at least 1") + url = f"https://pypi.org/pypi/specsmith/{version}/json" + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(url, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + retryable = error.code == 404 or 500 <= error.code < 600 + if not retryable or attempt == attempts: + raise + except urllib.error.URLError: + if attempt == attempts: + raise + time.sleep(delay_seconds) + raise AssertionError("unreachable") + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--seal", type=Path, required=True) @@ -52,15 +76,12 @@ def main() -> int: parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() + seal = json.loads(args.seal.read_text(encoding="utf-8")) version = args.version.removeprefix("v") - with urllib.request.urlopen( - f"https://pypi.org/pypi/specsmith/{version}/json", timeout=30 - ) as response: - payload = json.load(response) + payload = fetch_pypi_payload(version) if payload.get("info", {}).get("version") != version: raise ValueError("PyPI version response does not match the release") - seal = json.loads(args.seal.read_text(encoding="utf-8")) publication = { "version": version, "tag": args.tag, diff --git a/src/specsmith/__init__.py b/src/specsmith/__init__.py index cf59dc48..610a24ca 100644 --- a/src/specsmith/__init__.py +++ b/src/specsmith/__init__.py @@ -8,8 +8,8 @@ try: __version__: str = _pkg_version("specsmith") except PackageNotFoundError: # running from source without install - __version__ = "0.23.2" # fallback: keep in sync with pyproject.toml + __version__ = "0.23.3" # fallback: keep in sync with pyproject.toml # Governance/schema version — independent from the package version. # Bump this when the scaffold config schema or governance rules change. -GOVERNANCE_VERSION: str = "0.23.2" +GOVERNANCE_VERSION: str = "0.23.3" diff --git a/tests/test_release_evidence.py b/tests/test_release_evidence.py index d155e929..29c669bc 100644 --- a/tests/test_release_evidence.py +++ b/tests/test_release_evidence.py @@ -1,10 +1,13 @@ +import json from copy import deepcopy +from io import BytesIO from pathlib import Path +from urllib.error import HTTPError import pytest from scripts.release_evidence import create_receipt, create_seal, digest -from scripts.verify_publication import verify_pypi_files +from scripts.verify_publication import fetch_pypi_payload, verify_pypi_files def test_receipt_links_immutable_seal() -> None: @@ -38,3 +41,25 @@ def test_pypi_verification_requires_matching_artifact_digest(tmp_path: Path) -> } with pytest.raises(ValueError, match="digest mismatch"): verify_pypi_files(tmp_path, payload) + + +def test_pypi_fetch_retries_release_propagation(monkeypatch: pytest.MonkeyPatch) -> None: + responses: list[Exception | BytesIO] = [ + HTTPError("https://example.invalid", 404, "Not Found", {}, None), + BytesIO(json.dumps({"info": {"version": "1.2.3"}}).encode()), + ] + + def fake_urlopen(url: str, timeout: int) -> BytesIO: + response = responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + sleeps: list[float] = [] + monkeypatch.setattr("scripts.verify_publication.urllib.request.urlopen", fake_urlopen) + monkeypatch.setattr("scripts.verify_publication.time.sleep", sleeps.append) + + payload = fetch_pypi_payload("1.2.3", attempts=2, delay_seconds=0) + + assert payload["info"]["version"] == "1.2.3" + assert sleeps == [0] diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 202a3115..ffed1883 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -42,6 +42,8 @@ def test_tag_workflow_is_non_mutating_and_rejects_duplicates() -> None: assert "publication-receipt.json" in text assert "name: release-evidence" in text assert "--seal release-evidence/release-seal.json" in text + verify_block = text.split("verify-publication:", 1)[1].split("cleanup-dev-releases:", 1)[0] + assert "name: release-evidence" in verify_block def test_canonical_runbook_has_fixed_point_and_immutable_recovery() -> None: