diff --git a/README.md b/README.md index 6601e7f..c3b55c6 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ For application builds without a source checkout, use a pinned wheel from [GitHub Releases](https://github.com/PredictabilityAtScale/anomaly-detection/releases). The [release and library-consumer guide](docs/releases/README.md) explains the first release, dependency hashes, upgrades and Linux/Python 3.12 bundling for Guard. -Release candidates become downloadable there after the version tag workflow passes. +Version 0.2.1 includes the sparse-data progression in chapters 14–22. Tagged +artifacts are published after the workflow passes and the owner seals the draft. Install from a checkout with Python 3.11–3.13: diff --git a/docs/releases/0.2.1.md b/docs/releases/0.2.1.md new file mode 100644 index 0000000..d78fb17 --- /dev/null +++ b/docs/releases/0.2.1.md @@ -0,0 +1,43 @@ +# Anomalyzer 0.2.1 — explicit sparse-data semantics + +Applications can preserve unknown calendar periods, declare confirmed empty +additive volume, or use comparable event-index measurements without inventing +regular timestamps. Schema 1.0 remains unchanged; schema 1.1 gains per-source +policies, coverage, availability/revision inputs and inspectable transformations. + +- Frozen sparse references retain actual calendar phase, count eligible + training/calibration observations and report unsupported phases. +- Missing/incomplete/excluded positions and resets break patterns and change + comparisons. A measured range rule can operate before statistical learning. +- Relationships preserve unavailable reasons, zero-denominator semantics, + original source lineage and model exclusions. +- Sparse schema-1.1 multi-resolution retains native evidence and completed UTC + day views. Daily ratios divide sums; incomplete child periods cannot create a + completed total. Daily rules are explicit and cases survive policy replay. +- README progression chapters 14–22 include reproducible fixtures, checked + results and rendered figures, published with the existing documentation site. + +See the sparse-data API guide at `docs/sparse-data.md` and the release/consumer +guide at `docs/releases/README.md`. The installed-artifact checks cover the full +public API and an offline Linux x86_64/Python 3.12 Lambda-compatible bundle. +CI also records bounded 1,344-hour and event-index benchmark results and result +sizes. These are synthetic mechanics/performance checks, not abuse accuracy or +production cost claims. + +Explicit limits: event-index timestamps must be unique; adaptive/explicit trend +recipes use complete contiguous spans; multi-resolution children are frozen and +use fixed UTC days. Daily lagged/normalized/joint relationships, local-calendar +DST aggregation, simultaneous multi-seasonality, episode quarantine and durable +incremental state/diffing remain separate work. + +Guard continues to own collection contracts, profile compatibility, persistence, +scheduling, business impact/severity, incident lifecycle and opt-in notifications. +This release does not modify or deploy UsageTap Guard. Install the exact release +wheel and hash-pinned dependencies during the application's Linux build and +bundle them for offline runtime; no service, checkout, CLI or MCP process is +required. PyPI publication is not part of this release. + +Release automation now finds unpublished drafts through the authenticated release +list, resumes existing drafts without duplicate creation, and checks publication +by release ID. Version 0.2.0 remains an unpublished candidate; its original tag +is retained. Version 0.2.1 is the first published release of these features. diff --git a/docs/releases/README.md b/docs/releases/README.md index a2e0bb9..1520d98 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -1,17 +1,21 @@ # Release and use Anomalyzer -Anomalyzer is a normal Python dependency. Version **0.2.0** is the first release -candidate; nothing is uploaded to PyPI by this workflow. GitHub Releases is the +Anomalyzer is a normal Python dependency. Version **0.2.1** is the first published +release; nothing is uploaded to PyPI by this workflow. GitHub Releases is the initial distribution channel. Publishing the library does not integrate it into UsageTap Guard. +The original `v0.2.0` tag remains an unpublished candidate after a draft-lookup +failure. Its tag and draft are retained; the fixed publication helper ships in +`v0.2.1`. + ## Four terms | Term | Meaning | | --- | --- | | Wheel (`.whl`) | Ready-to-install Python package. Use this for Guard. Anomalyzer's wheel works across supported operating systems; its dependencies may need platform-specific wheels. | | Source distribution (`.tar.gz`, or sdist) | Source needed to build the package. Useful for rebuilding and inspection; installation also needs the declared build backend. | -| Version tag (`v0.2.0`) | Permanent Git name for the exact source revision released. It must match `_version.py`; never move or reuse it. | +| Version tag (`v0.2.1`) | Permanent Git name for the exact source revision released. It must match `_version.py`; never move or reuse it. | | Dependency pin | An exact version or artifact URL, plus a SHA-256 fingerprint. A hash checks that downloaded bytes match the intended artifact. Pin transitive dependencies too. | The package supports Python 3.11, 3.12 and 3.13. Version lives only in @@ -41,7 +45,7 @@ it for package metadata and the public `__version__` imports it. Before choosing a version, inspect existing tags/releases and the immutability setting. Never reuse a published version or move a version tag. -## First release: 0.2.0 +## First release: 0.2.1 Run these commands from a clean checkout of the merged `main` branch. Commands work in PowerShell or Bash except where explicitly labelled. @@ -57,16 +61,16 @@ uv sync --locked --extra dev uv run pytest -q uv run python examples/run_demo.py uv run python examples/run_agentic_demo.py -uv run python scripts/release.py check-version --tag v0.2.0 +uv run python scripts/release.py check-version --tag v0.2.1 ``` -`git status --short` must be empty. If `v0.2.0` already exists, stop and choose +`git status --short` must be empty. If `v0.2.1` already exists, stop and choose the next unused version through a PR. Otherwise create an annotated tag on the reviewed commit and push it (do not use `--force`): ```text -git tag -a v0.2.0 -m "Anomalyzer 0.2.0" -git push origin v0.2.0 +git tag -a v0.2.1 -m "Anomalyzer 0.2.1" +git push origin v0.2.1 ``` Open the repository's **Actions → Release** run. It runs all tests on @@ -78,10 +82,10 @@ passes does it create a draft and attach the tested files. Publication is one explicit owner step, using the already tested files: ```text -git switch --detach v0.2.0 -gh release download v0.2.0 --repo PredictabilityAtScale/anomaly-detection --dir release-v0.2.0 +git switch --detach v0.2.1 +gh release download v0.2.1 --repo PredictabilityAtScale/anomaly-detection --dir release-v0.2.1 git rev-parse HEAD -python scripts/publish_release.py --dist release-v0.2.0 --tag v0.2.0 --revision --publish +python scripts/publish_release.py --dist release-v0.2.1 --tag v0.2.1 --revision --publish git switch main ``` @@ -100,14 +104,14 @@ any release asset. Assets live under [GitHub Releases](https://github.com/PredictabilityAtScale/anomaly-detection/releases), in the release for the selected tag: -- `anomalyzer-0.2.0-py3-none-any.whl` and `anomalyzer-0.2.0.tar.gz`. +- `anomalyzer-0.2.1-py3-none-any.whl` and `anomalyzer-0.2.1.tar.gz`. - `release-manifest.json`: version, tag, full source commit, file sizes and hashes. - `SHA256SUMS`: hashes of all assets including the manifest (except itself). - `runtime-dependencies.txt`: exact dependency versions and accepted hashes exported from the repository lock, including platform markers. - `requirements-guard-py312-linux-x86_64.txt`: those dependencies plus the exact GitHub wheel URL and its hash. This is an installable starting lock for Guard. -- `requirements-offline.txt`: the same pins using `anomalyzer==0.2.0` so pip can +- `requirements-offline.txt`: the same pins using `anomalyzer==0.2.1` so pip can install entirely from a local wheelhouse. CI's **Artifacts** section also holds `release-dist-` and the @@ -152,11 +156,11 @@ analysis needs no checkout, CLI, MCP server, hosted Anomalyzer service or networ 1. Download the chosen Release's assets and verify `SHA256SUMS`. For example: ```text - gh release download v0.2.0 --repo PredictabilityAtScale/anomaly-detection --dir release + gh release download v0.2.1 --repo PredictabilityAtScale/anomaly-detection --dir release ``` In Linux/WSL/Bash: `(cd release && sha256sum --check SHA256SUMS)`. - In PowerShell, `Get-FileHash release/anomalyzer-0.2.0-py3-none-any.whl -Algorithm SHA256` + In PowerShell, `Get-FileHash release/anomalyzer-0.2.1-py3-none-any.whl -Algorithm SHA256` shows the wheel's hash; compare it with the manifest/checksum file. Also check the manifest's tag and commit against the intended release. Hashes identify bytes; use the trusted GitHub repository/release as their source. @@ -165,7 +169,7 @@ analysis needs no checkout, CLI, MCP server, hosted Anomalyzer service or networ inputs. Its final requirement has this form, with a real generated hash: ```text - anomalyzer @ https://github.com/PredictabilityAtScale/anomaly-detection/releases/download/v0.2.0/anomalyzer-0.2.0-py3-none-any.whl --hash=sha256: + anomalyzer @ https://github.com/PredictabilityAtScale/anomaly-detection/releases/download/v0.2.1/anomalyzer-0.2.1-py3-none-any.whl --hash=sha256: ``` Install the actual file, not this placeholder. It also pins Pydantic, diff --git a/scripts/publish_release.py b/scripts/publish_release.py index 6296fd2..5e2bcfd 100644 --- a/scripts/publish_release.py +++ b/scripts/publish_release.py @@ -14,6 +14,20 @@ def gh(*args): return subprocess.check_output(["gh", *args], text=True).strip() +def find_release(tag): + """The by-tag endpoint omits drafts; the authenticated release list includes them.""" + endpoint = f"repos/{REPOSITORY}/releases/tags/{tag}" + found = subprocess.run(["gh", "api", endpoint], capture_output=True, text=True) + if not found.returncode: + return json.loads(found.stdout) + error = json.loads(found.stdout) + if str(error.get("status")) != "404": + raise RuntimeError(found.stderr) + pages = json.loads(gh("api", "--paginate", "--slurp", + f"repos/{REPOSITORY}/releases?per_page=100")) + return next((item for page in pages for item in page if item["tag_name"] == tag), None) + + def publish(dist, tag, revision, *, finalize=False): verify(dist, revision, tag) # This endpoint requires admin-read, unavailable to GITHUB_TOKEN. Only the @@ -25,12 +39,8 @@ def publish(dist, tag, revision, *, finalize=False): remote_sha = json.loads(gh("api", f"repos/{REPOSITORY}/commits/{tag}"))["sha"] if remote_sha != revision: raise ValueError("Remote tag no longer points to the tested source revision") - endpoint = f"repos/{REPOSITORY}/releases/tags/{tag}" - found = subprocess.run(["gh", "api", endpoint], capture_output=True, text=True) - if found.returncode: - error = json.loads(found.stdout) - if str(error.get("status")) != "404": - raise RuntimeError(found.stderr) + release = find_release(tag) + if release is None: with tempfile.TemporaryDirectory() as temp: notes = Path(temp) / "notes.md" feature_path = Path(__file__).resolve().parents[1] / "docs/releases" / f"{tag[1:]}.md" @@ -46,9 +56,9 @@ def publish(dist, tag, revision, *, finalize=False): encoding="utf-8") gh("release", "create", tag, "--repo", REPOSITORY, "--draft", "--verify-tag", "--target", revision, "--title", f"Anomalyzer {tag}", "--notes-file", str(notes)) - release = json.loads(gh("api", endpoint)) - else: - release = json.loads(found.stdout) + release = find_release(tag) + if release is None: + raise RuntimeError("Created draft is not visible yet; rerun to resume it safely") expected = {p.name: p for p in dist.iterdir() if p.is_file() and p.name != ".gitignore"} existing = {item["name"] for item in release["assets"]} if not existing <= set(expected): @@ -70,7 +80,7 @@ def publish(dist, tag, revision, *, finalize=False): print(f"Verified draft staged for {tag}; owner must run this command with --publish to seal it") return gh("release", "edit", tag, "--repo", REPOSITORY, "--draft=false") - sealed = json.loads(gh("api", endpoint)) + sealed = json.loads(gh("api", f"repos/{REPOSITORY}/releases/{release['id']}")) if not sealed.get("immutable"): raise RuntimeError("GitHub did not report an immutable release; investigate repository settings") print(sealed["html_url"]) diff --git a/src/anomalyzer/_version.py b/src/anomalyzer/_version.py index 242d872..417c5f7 100644 --- a/src/anomalyzer/_version.py +++ b/src/anomalyzer/_version.py @@ -1,3 +1,3 @@ """Authoritative distribution and runtime version (read by Hatchling).""" -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/tests/test_release.py b/tests/test_release.py index 6adfb41..1a60e93 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -82,3 +82,59 @@ def test_changed_artifact_fails_checksum_verification(monkeypatch, tmp_path): "artifacts": {"wheel.whl": {"sha256": "0" * 64, "size": 21}}})) with pytest.raises(AssertionError, match="wheel.whl"): release.verify(tmp_path, "a" * 40, f"v{release.version()}") + + +@pytest.mark.parametrize("already_created", [False, True]) +def test_draft_lookup_stages_and_resumes_without_duplicate_creation(monkeypatch, tmp_path, already_created): + dist = tmp_path / "dist" + dist.mkdir() + (dist / "wheel.whl").write_bytes(b"tested") + monkeypatch.setattr(publisher, "verify", lambda *args: None) + monkeypatch.setattr(publisher.subprocess, "run", lambda *args, **kwargs: SimpleNamespace( + returncode=1, stdout=json.dumps({"status": "404"}), stderr="Not Found")) + created = already_created + calls = [] + def fake_gh(*args): + nonlocal created + calls.append(args) + if args[0] == "api" and "/commits/" in args[1]: + return json.dumps({"sha": "a" * 40}) + if args[:4] == ("api", "--paginate", "--slurp", f"repos/{publisher.REPOSITORY}/releases?per_page=100"): + # Include another tag on the first page to exercise pagination/filtering. + return json.dumps([[{"tag_name": "v0.0.1"}], + [{"id": 42, "tag_name": "v0.2.1", "draft": True, "assets": []}] if created else []]) + if args[:2] == ("release", "create"): + assert not created + created = True + return "" + if args[:2] == ("release", "upload"): + assert Path(args[3]).read_bytes() == b"tested" + return "" + raise AssertionError(f"Unexpected GitHub call: {args}") + monkeypatch.setattr(publisher, "gh", fake_gh) + publisher.publish(dist, "v0.2.1", "a" * 40) + assert sum(call[:2] == ("release", "create") for call in calls) == int(not already_created) + assert sum(call[:2] == ("release", "upload") for call in calls) == 1 + assert not any(call[:2] == ("release", "edit") for call in calls) + + +def test_final_publication_verifies_release_by_id(monkeypatch, tmp_path): + dist = tmp_path / "dist" + dist.mkdir() + monkeypatch.setattr(publisher, "verify", lambda *args: None) + draft = {"id": 42, "draft": True, "assets": []} + monkeypatch.setattr(publisher, "find_release", lambda tag: draft) + calls = [] + def fake_gh(*args): + calls.append(args) + if args == ("api", f"repos/{publisher.REPOSITORY}/immutable-releases"): + return json.dumps({"enabled": True}) + if args == ("api", f"repos/{publisher.REPOSITORY}/commits/v0.2.1"): + return json.dumps({"sha": "a" * 40}) + if args == ("api", f"repos/{publisher.REPOSITORY}/releases/42"): + return json.dumps({"immutable": True, "html_url": "https://example.test/release"}) + assert args[:2] == ("release", "edit") + return "" + monkeypatch.setattr(publisher, "gh", fake_gh) + publisher.publish(dist, "v0.2.1", "a" * 40, finalize=True) + assert calls[-1] == ("api", f"repos/{publisher.REPOSITORY}/releases/42")