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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
43 changes: 43 additions & 0 deletions docs/releases/0.2.1.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 20 additions & 16 deletions docs/releases/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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 <full-SHA-printed-above> --publish
python scripts/publish_release.py --dist release-v0.2.1 --tag v0.2.1 --revision <full-SHA-printed-above> --publish
git switch main
```

Expand All @@ -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-<commit>` and the
Expand Down Expand Up @@ -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.
Expand All @@ -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:<generated-wheel-hash>
anomalyzer @ https://github.com/PredictabilityAtScale/anomaly-detection/releases/download/v0.2.1/anomalyzer-0.2.1-py3-none-any.whl --hash=sha256:<generated-wheel-hash>
```

Install the actual file, not this placeholder. It also pins Pydantic,
Expand Down
30 changes: 20 additions & 10 deletions scripts/publish_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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):
Expand All @@ -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"])
Expand Down
2 changes: 1 addition & 1 deletion src/anomalyzer/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Authoritative distribution and runtime version (read by Hatchling)."""

__version__ = "0.2.0"
__version__ = "0.2.1"
56 changes: 56 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading