Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/SPECSMITH.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 26 additions & 5 deletions scripts/verify_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/specsmith/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
27 changes: 26 additions & 1 deletion tests/test_release_evidence.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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]
2 changes: 2 additions & 0 deletions tests/test_release_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down