releases.py
python
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40
docs: add | jq convention to --json section of agent-guide
Sonnet 4.6
1 day ago
| 1 | """muse.core.releases — release layer for the Muse VCS. |
| 2 | |
| 3 | Everything that reads, writes, or queries release records lives here. |
| 4 | |
| 5 | Public API |
| 6 | ---------- |
| 7 | ReleaseDict |
| 8 | JSON-serialisable TypedDict for ReleaseRecord wire format. |
| 9 | |
| 10 | ReleaseRecord |
| 11 | Versioned release dataclass with to_dict / from_dict. |
| 12 | |
| 13 | compute_release_id |
| 14 | Content-addressed ID derivation for releases. |
| 15 | |
| 16 | release_path |
| 17 | On-disk path helper. |
| 18 | |
| 19 | write_release / read_release |
| 20 | Core release I/O. |
| 21 | |
| 22 | get_release_for_tag / list_releases / delete_release |
| 23 | Query and mutation helpers. |
| 24 | |
| 25 | build_changelog |
| 26 | Walk the commit graph to produce a typed changelog for a release. |
| 27 | """ |
| 28 | from __future__ import annotations |
| 29 | |
| 30 | import datetime |
| 31 | import json as _json |
| 32 | import logging |
| 33 | import pathlib |
| 34 | from dataclasses import dataclass, field |
| 35 | from typing import TypedDict |
| 36 | |
| 37 | from muse.core.io import _read_msgpack_dict, _write_json_atomic |
| 38 | from muse.core.paths import releases_dir as _releases_dir |
| 39 | from muse.core.record_helpers import _int_val, _str_list, _str_val |
| 40 | from muse.core.semver import ( |
| 41 | ChangelogEntry, |
| 42 | ReleaseChannel, |
| 43 | SemVerTag, |
| 44 | _CHANNEL_MAP, |
| 45 | ) |
| 46 | from muse.core.types import MsgpackDict, SemVerBump, content_hash, short_id, split_id |
| 47 | |
| 48 | logger = logging.getLogger(__name__) |
| 49 | |
| 50 | |
| 51 | # --------------------------------------------------------------------------- |
| 52 | # Private deserialisation helpers |
| 53 | # --------------------------------------------------------------------------- |
| 54 | |
| 55 | def _sem_ver_bump_val(d: MsgpackDict) -> SemVerBump: |
| 56 | """Extract and validate a ``sem_ver_bump`` field from a raw storage dict. |
| 57 | |
| 58 | Falls back to ``"none"`` if the value is absent or not a recognised |
| 59 | Literal — guards against tampered or forward-versioned records. |
| 60 | """ |
| 61 | val = _str_val(d, "sem_ver_bump", "none") |
| 62 | if val == "major": |
| 63 | return "major" |
| 64 | if val == "minor": |
| 65 | return "minor" |
| 66 | if val == "patch": |
| 67 | return "patch" |
| 68 | return "none" |
| 69 | |
| 70 | |
| 71 | def _parse_semver_tag(d: MsgpackDict, key: str) -> SemVerTag: |
| 72 | """Extract a nested :class:`SemVerTag` from a raw storage mapping. |
| 73 | |
| 74 | Returns a zeroed ``SemVerTag`` if the key is absent or the value is not |
| 75 | a dict — callers can inspect ``major == 0`` to detect the fallback. |
| 76 | """ |
| 77 | val = d.get(key) |
| 78 | if isinstance(val, dict): |
| 79 | return SemVerTag( |
| 80 | major=_int_val(val, "major"), |
| 81 | minor=_int_val(val, "minor"), |
| 82 | patch=_int_val(val, "patch"), |
| 83 | pre=_str_val(val, "pre"), |
| 84 | build=_str_val(val, "build"), |
| 85 | ) |
| 86 | return SemVerTag(major=0, minor=0, patch=0, pre="", build="") |
| 87 | |
| 88 | |
| 89 | def _parse_changelog_entries(d: MsgpackDict, key: str) -> list[ChangelogEntry]: |
| 90 | """Extract a ``list[ChangelogEntry]`` from a raw storage mapping. |
| 91 | |
| 92 | Silently skips items that are not dicts — defensive deserialization |
| 93 | at the boundary between wire format and in-memory types. |
| 94 | """ |
| 95 | val = d.get(key) |
| 96 | if not isinstance(val, list): |
| 97 | return [] |
| 98 | entries: list[ChangelogEntry] = [] |
| 99 | for item in val: |
| 100 | if not isinstance(item, dict): |
| 101 | continue |
| 102 | entries.append( |
| 103 | ChangelogEntry( |
| 104 | commit_id=_str_val(item, "commit_id"), |
| 105 | message=_str_val(item, "message"), |
| 106 | sem_ver_bump=_sem_ver_bump_val(item), |
| 107 | breaking_changes=_str_list(item, "breaking_changes"), |
| 108 | author=_str_val(item, "author"), |
| 109 | committed_at=_str_val(item, "committed_at"), |
| 110 | agent_id=_str_val(item, "agent_id"), |
| 111 | model_id=_str_val(item, "model_id"), |
| 112 | ) |
| 113 | ) |
| 114 | return entries |
| 115 | |
| 116 | |
| 117 | # --------------------------------------------------------------------------- |
| 118 | # Wire-format TypedDict |
| 119 | # --------------------------------------------------------------------------- |
| 120 | |
| 121 | class ReleaseDict(TypedDict): |
| 122 | """JSON-serialisable representation of a ReleaseRecord.""" |
| 123 | |
| 124 | release_id: str |
| 125 | repo_id: str |
| 126 | tag: str |
| 127 | semver: SemVerTag |
| 128 | channel: str |
| 129 | commit_id: str |
| 130 | snapshot_id: str |
| 131 | title: str |
| 132 | body: str |
| 133 | changelog: list[ChangelogEntry] |
| 134 | agent_id: str |
| 135 | model_id: str |
| 136 | is_draft: bool |
| 137 | gpg_signature: str |
| 138 | created_at: str |
| 139 | |
| 140 | |
| 141 | # --------------------------------------------------------------------------- |
| 142 | # ID derivation |
| 143 | # --------------------------------------------------------------------------- |
| 144 | |
| 145 | def compute_release_id(repo_id: str, tag: str, commit_id: str) -> str: |
| 146 | """Return the content-addressed ``sha256:`` ID for a release. |
| 147 | |
| 148 | The ID is derived from the three fields that uniquely identify a release: |
| 149 | its repository, the semver tag string, and the commit it pins. |
| 150 | """ |
| 151 | return content_hash({"commit_id": commit_id, "repo_id": repo_id, "tag": tag}) |
| 152 | |
| 153 | |
| 154 | # --------------------------------------------------------------------------- |
| 155 | # ReleaseRecord dataclass |
| 156 | # --------------------------------------------------------------------------- |
| 157 | |
| 158 | @dataclass |
| 159 | class ReleaseRecord: |
| 160 | """A versioned release attached to a commit. |
| 161 | |
| 162 | A release is richer than a Git tag: |
| 163 | |
| 164 | - ``semver`` is a parsed struct (major/minor/patch/pre/build) — queryable |
| 165 | by version component without string parsing. |
| 166 | - ``channel`` replaces the boolean ``is_prerelease`` flag with a named |
| 167 | distribution channel: stable | beta | alpha | nightly. |
| 168 | - ``changelog`` is auto-generated from the typed ``sem_ver_bump`` and |
| 169 | ``breaking_changes`` fields on commits since the previous release, so no |
| 170 | conventional-commit parsing is needed. |
| 171 | - ``snapshot_id`` makes the release byte-for-byte reproducible from the |
| 172 | content-addressed object store forever. |
| 173 | - ``agent_id`` / ``model_id`` surface AI provenance from the tip commit. |
| 174 | - ``gpg_signature`` signs the release for tamper-evident distribution. |
| 175 | """ |
| 176 | |
| 177 | release_id: str |
| 178 | repo_id: str |
| 179 | tag: str |
| 180 | semver: SemVerTag |
| 181 | channel: ReleaseChannel |
| 182 | commit_id: str |
| 183 | snapshot_id: str |
| 184 | title: str |
| 185 | body: str |
| 186 | changelog: list[ChangelogEntry] |
| 187 | agent_id: str = "" |
| 188 | model_id: str = "" |
| 189 | is_draft: bool = False |
| 190 | gpg_signature: str = "" |
| 191 | created_at: datetime.datetime = field( |
| 192 | default_factory=lambda: datetime.datetime.now(datetime.timezone.utc) |
| 193 | ) |
| 194 | |
| 195 | def to_dict(self) -> ReleaseDict: |
| 196 | return ReleaseDict( |
| 197 | release_id=self.release_id, |
| 198 | repo_id=self.repo_id, |
| 199 | tag=self.tag, |
| 200 | semver=self.semver, |
| 201 | channel=self.channel, |
| 202 | commit_id=self.commit_id, |
| 203 | snapshot_id=self.snapshot_id, |
| 204 | title=self.title, |
| 205 | body=self.body, |
| 206 | changelog=list(self.changelog), |
| 207 | agent_id=self.agent_id, |
| 208 | model_id=self.model_id, |
| 209 | is_draft=self.is_draft, |
| 210 | gpg_signature=self.gpg_signature, |
| 211 | created_at=self.created_at.isoformat(), |
| 212 | ) |
| 213 | |
| 214 | @classmethod |
| 215 | def from_dict(cls, d: "MsgpackDict | ReleaseDict") -> "ReleaseRecord": |
| 216 | """Deserialise a :class:`ReleaseRecord` from a plain dict.""" |
| 217 | created_at_str = _str_val(d, "created_at") |
| 218 | try: |
| 219 | created_at = datetime.datetime.fromisoformat(created_at_str) |
| 220 | except ValueError: |
| 221 | created_at = datetime.datetime.now(datetime.timezone.utc) |
| 222 | channel = _CHANNEL_MAP.get(_str_val(d, "channel", "stable"), "stable") |
| 223 | is_draft_val = d.get("is_draft", False) |
| 224 | return cls( |
| 225 | release_id=_str_val(d, "release_id"), |
| 226 | repo_id=_str_val(d, "repo_id"), |
| 227 | tag=_str_val(d, "tag"), |
| 228 | semver=_parse_semver_tag(d, "semver"), |
| 229 | channel=channel, |
| 230 | commit_id=_str_val(d, "commit_id"), |
| 231 | snapshot_id=_str_val(d, "snapshot_id"), |
| 232 | title=_str_val(d, "title"), |
| 233 | body=_str_val(d, "body"), |
| 234 | changelog=_parse_changelog_entries(d, "changelog"), |
| 235 | agent_id=_str_val(d, "agent_id"), |
| 236 | model_id=_str_val(d, "model_id"), |
| 237 | is_draft=bool(is_draft_val), |
| 238 | gpg_signature=_str_val(d, "gpg_signature"), |
| 239 | created_at=created_at, |
| 240 | ) |
| 241 | |
| 242 | |
| 243 | # --------------------------------------------------------------------------- |
| 244 | # Path helper |
| 245 | # --------------------------------------------------------------------------- |
| 246 | |
| 247 | def release_path( |
| 248 | repo_root: pathlib.Path, repo_id: str, release_id: str |
| 249 | ) -> pathlib.Path: |
| 250 | """Return the on-disk path for a release record. |
| 251 | |
| 252 | Path shape: ``.muse/releases/<repo-algo>/<repo-hex>/<rel-algo>/<rel-hex>.json`` |
| 253 | |
| 254 | Follows the same two-level algorithm convention as ``tag_path``. |
| 255 | |
| 256 | Args: |
| 257 | repo_root: Repository root directory. |
| 258 | repo_id: A ``<algo>:<hex>`` repo ID, or bare 64-char hex. |
| 259 | release_id: A ``<algo>:<hex>`` release ID, or bare 64-char hex. |
| 260 | |
| 261 | Returns: |
| 262 | Absolute path to the JSON file for this release. |
| 263 | """ |
| 264 | r_algo, r_hex = split_id(repo_id) |
| 265 | rl_algo, rl_hex = split_id(release_id) |
| 266 | return _releases_dir(repo_root) / r_algo / r_hex / rl_algo / f"{rl_hex}.json" |
| 267 | |
| 268 | |
| 269 | # --------------------------------------------------------------------------- |
| 270 | # Release I/O |
| 271 | # --------------------------------------------------------------------------- |
| 272 | |
| 273 | def _read_release_or_migrate(path: pathlib.Path) -> "ReleaseRecord | None": |
| 274 | """Read a release from *path*, handling both .json and legacy .msgpack files. |
| 275 | |
| 276 | If *path* is a .msgpack file, the record is silently migrated to .json |
| 277 | and the old file deleted. |
| 278 | """ |
| 279 | try: |
| 280 | if path.suffix == ".json": |
| 281 | d: MsgpackDict = _json.loads(path.read_bytes().decode("utf-8")) |
| 282 | else: |
| 283 | d = _read_msgpack_dict(path) |
| 284 | json_path = path.with_suffix(".json") |
| 285 | _write_json_atomic(json_path, d) |
| 286 | path.unlink(missing_ok=True) |
| 287 | return ReleaseRecord.from_dict(d) |
| 288 | except Exception: |
| 289 | return None |
| 290 | |
| 291 | |
| 292 | def write_release(repo_root: pathlib.Path, release: ReleaseRecord) -> None: |
| 293 | """Persist a release record to ``.muse/releases/<repo-algo>/<repo-hex>/<rel-algo>/<rel-hex>.json``.""" |
| 294 | path = release_path(repo_root, release.repo_id, release.release_id) |
| 295 | path.parent.mkdir(parents=True, exist_ok=True) |
| 296 | _write_json_atomic(path, release.to_dict()) |
| 297 | logger.debug("✅ Stored release %r (%s)", release.tag, short_id(release.release_id)) |
| 298 | |
| 299 | |
| 300 | def read_release( |
| 301 | repo_root: pathlib.Path, repo_id: str, release_id: str |
| 302 | ) -> ReleaseRecord | None: |
| 303 | """Load a release by its ID, or ``None`` if it does not exist or is corrupt.""" |
| 304 | path = release_path(repo_root, repo_id, release_id) |
| 305 | if path.exists(): |
| 306 | return _read_release_or_migrate(path) |
| 307 | # Fallback: legacy .msgpack file (silent upgrade to .json) |
| 308 | legacy = path.with_suffix(".msgpack") |
| 309 | if legacy.exists(): |
| 310 | return _read_release_or_migrate(legacy) |
| 311 | return None |
| 312 | |
| 313 | |
| 314 | def get_release_for_tag( |
| 315 | repo_root: pathlib.Path, repo_id: str, tag: str |
| 316 | ) -> ReleaseRecord | None: |
| 317 | """Return the release whose version tag matches *tag*, or ``None``. |
| 318 | |
| 319 | Searches all releases including drafts so callers can inspect or delete |
| 320 | a draft before it is published. |
| 321 | """ |
| 322 | for release in list_releases(repo_root, repo_id, include_drafts=True): |
| 323 | if release.tag == tag: |
| 324 | return release |
| 325 | return None |
| 326 | |
| 327 | |
| 328 | def list_releases( |
| 329 | repo_root: pathlib.Path, |
| 330 | repo_id: str, |
| 331 | channel: ReleaseChannel | None = None, |
| 332 | include_drafts: bool = False, |
| 333 | ) -> list[ReleaseRecord]: |
| 334 | """Return all releases, newest first. |
| 335 | |
| 336 | Args: |
| 337 | repo_root: Repository root. |
| 338 | repo_id: Content-addressed repo ID (``<algo>:<hex>`` or bare hex). |
| 339 | channel: Filter by channel; ``None`` returns all channels. |
| 340 | include_drafts: When ``False`` (default) draft releases are hidden. |
| 341 | """ |
| 342 | r_algo, r_hex = split_id(repo_id) |
| 343 | repo_dir = _releases_dir(repo_root) / r_algo / r_hex |
| 344 | if not repo_dir.exists(): |
| 345 | return [] |
| 346 | results: list[ReleaseRecord] = [] |
| 347 | for path in repo_dir.glob("*/*"): |
| 348 | if path.suffix not in (".json", ".msgpack"): |
| 349 | continue |
| 350 | r = _read_release_or_migrate(path) |
| 351 | if r is None: |
| 352 | logger.critical("❌ Corrupt release file %s — skipped in listing", path) |
| 353 | continue |
| 354 | if r.is_draft and not include_drafts: |
| 355 | continue |
| 356 | if channel is not None and r.channel != channel: |
| 357 | continue |
| 358 | results.append(r) |
| 359 | results.sort(key=lambda r: r.created_at, reverse=True) |
| 360 | return results |
| 361 | |
| 362 | |
| 363 | def delete_release(repo_root: pathlib.Path, repo_id: str, release_id: str) -> bool: |
| 364 | """Delete a release record. Returns ``True`` if it existed. |
| 365 | |
| 366 | Callers are responsible for enforcing that only draft releases may be |
| 367 | deleted. This function performs no such guard — enforce at the CLI/API |
| 368 | layer. |
| 369 | """ |
| 370 | path = release_path(repo_root, repo_id, release_id) |
| 371 | if path.exists(): |
| 372 | path.unlink() |
| 373 | logger.debug("🗑️ Deleted release %s", short_id(release_id)) |
| 374 | return True |
| 375 | return False |
| 376 | |
| 377 | |
| 378 | def build_changelog( |
| 379 | repo_root: pathlib.Path, |
| 380 | from_commit_id: str | None, |
| 381 | to_commit_id: str, |
| 382 | max_commits: int = 500, |
| 383 | ) -> list[ChangelogEntry]: |
| 384 | """Walk the commit graph from *to_commit_id* back to *from_commit_id*. |
| 385 | |
| 386 | Returns a list of :class:`ChangelogEntry` dicts, oldest first, excluding |
| 387 | merge commits and ``sem_ver_bump="none"`` commits (they carry no user-visible |
| 388 | change). *from_commit_id* is excluded; *to_commit_id* is included. |
| 389 | |
| 390 | Args: |
| 391 | repo_root: Repository root. |
| 392 | from_commit_id: The last release commit (exclusive start), or ``None`` |
| 393 | for a first release (walks back to the initial commit). |
| 394 | to_commit_id: The HEAD commit to release (inclusive end). |
| 395 | max_commits: Safety cap — changelog never exceeds this many entries. |
| 396 | """ |
| 397 | from muse.core.commits import walk_commits_between # local to avoid circular import |
| 398 | raw = walk_commits_between(repo_root, to_commit_id, from_commit_id, max_commits) |
| 399 | entries: list[ChangelogEntry] = [] |
| 400 | for commit in reversed(raw): # oldest first |
| 401 | entries.append(ChangelogEntry( |
| 402 | commit_id=commit.commit_id, |
| 403 | message=commit.message, |
| 404 | sem_ver_bump=commit.sem_ver_bump, |
| 405 | breaking_changes=list(commit.breaking_changes), |
| 406 | author=commit.author, |
| 407 | committed_at=commit.committed_at.isoformat(), |
| 408 | agent_id=commit.agent_id, |
| 409 | model_id=commit.model_id, |
| 410 | )) |
| 411 | return entries |
File History
1 commit
sha256:e6465e8a9b7fa8e6223ed4a3576e96c568c913ae2caeb9c31f15e7a81b250b40
docs: add | jq convention to --json section of agent-guide
Sonnet 4.6
1 day ago