gabriel / muse public
release.py python
1,129 lines 42.7 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
1 """muse release — create and manage versioned releases on MuseHub.
2
3 A Muse release is richer than a Git tag:
4
5 - Semver parsed into queryable components (major/minor/patch/pre/build).
6 - Named distribution channel (stable | beta | alpha | nightly) rather than
7 a boolean ``is_prerelease`` flag.
8 - Changelog auto-generated from typed ``sem_ver_bump`` and
9 ``breaking_changes`` fields on commits since the previous release — no
10 conventional-commit parsing required.
11 - ``snapshot_id`` makes the release reproducible from the content-addressed
12 object store forever.
13 - ``agent_id`` / ``model_id`` surface AI provenance from the tip commit.
14
15 Usage::
16
17 muse release — list local releases (default)
18 muse release add <tag> — create a local release at HEAD
19 muse release list — list local releases
20 muse release suggest — infer next version from commit graph
21 muse release show <tag> — inspect one release
22 muse release push <tag> — push a release to a remote
23 muse release push <tag> --dry-run — validate push without transmitting
24 muse release delete <tag> — delete a local release record
25 muse release delete <tag> --remote <remote> — retract a release from a remote
26 muse release delete <tag> --dry-run — show what would be deleted
27
28 All subcommands accept ``--json`` for machine-readable output::
29
30 muse release add v1.2.0 --title "Drop" --body "..." --json
31 muse release push v1.2.0 --remote origin --json
32 muse release delete v1.2.0 --yes --json
33 muse release suggest --json
34
35 Deletion semantics::
36
37 Deleting a release removes the named label only. The underlying commit and
38 snapshot remain in the content-addressed object store forever — they are
39 still reachable by their SHA-256 and are fully reproducible. Only the
40 named pointer is removed, not the content it referenced.
41
42 suggest semantics::
43
44 ``muse release suggest`` derives the next version entirely from the commit
45 graph — no human input required. For each unreleased commit, Muse recorded
46 a ``sem_ver_bump`` (none/patch/minor/major) and ``breaking_changes`` at
47 commit time by diffing the public symbol graph. ``suggest`` aggregates
48 these to the highest bump seen since the last release and applies semver
49 arithmetic.
50
51 Pre-1.0 adjustment (major = 0): a major structural break bumps the minor
52 component (0.x+1.0) rather than crossing the 1.0 boundary, matching the
53 semver spec for unstable APIs.
54
55 The result is a machine-verifiable claim: ``suggested_tag`` is exactly the
56 version implied by the code, not a number someone typed.
57
58 Examples::
59
60 muse release add v1.2.0 --title "Summer drop" --body "Bug fixes"
61 muse release add v1.3.0-beta.1 --channel beta --draft
62 muse release push v1.2.0 --remote origin
63 muse release list --channel stable
64 muse release suggest
65 muse release suggest --base v1.1.0
66 muse release show v1.2.0 --json
67 muse release delete v1.2.0-beta.1
68 muse release delete v1.2.0 --remote origin
69 """
70
71 from __future__ import annotations
72
73 import argparse
74 import json
75 import logging
76 import pathlib
77 import sys
78 import uuid
79 from typing import TypedDict
80
81 from muse.cli.config import get_signing_identity, get_remote
82 from muse.core.errors import ExitCode
83 from muse.core.repo import read_repo_id, require_repo
84 from muse.core.semver import (
85 ReleaseChannel,
86 _CHANNEL_MAP,
87 parse_semver,
88 semver_channel,
89 semver_to_str,
90 )
91 from muse.core.store import (
92 ReleaseRecord,
93 build_changelog,
94 delete_release,
95 get_head_commit_id,
96 get_release_for_tag,
97 list_releases,
98 read_current_branch,
99 resolve_commit_ref,
100 walk_commits_between,
101 write_release,
102 )
103 from muse.domain import SemVerBump
104 from muse.core.transport import TransportError, make_transport
105 from muse.core.validation import sanitize_display
106
107
108 logger = logging.getLogger(__name__)
109
110 _CHANNELS: frozenset[str] = frozenset({"stable", "beta", "alpha", "nightly"})
111
112
113 # ---------------------------------------------------------------------------
114 # JSON wire formats
115 # ---------------------------------------------------------------------------
116
117
118
119 class _ReleasePushJson(TypedDict):
120 """JSON output for ``muse release push``."""
121
122 status: str # "pushed" | "dry_run"
123 tag: str
124 remote: str
125 release_id: str
126 dry_run: bool
127
128
129 class _ReleaseDeleteJson(TypedDict):
130 """JSON output for ``muse release delete``."""
131
132 status: str # "deleted" | "aborted" | "dry_run"
133 tag: str
134 was_draft: bool
135 remote_retracted: bool
136 dry_run: bool
137
138
139 # ---------------------------------------------------------------------------
140 # Helpers
141 # ---------------------------------------------------------------------------
142
143
144 def _resolve_remote_url(root: pathlib.Path, remote: str) -> str:
145 """Resolve a named remote to its URL, exiting with USER_ERROR if not found."""
146 url = get_remote(remote, root)
147 if not url:
148 print(f"❌ Remote '{sanitize_display(remote)}' is not configured.", file=sys.stderr)
149 raise SystemExit(ExitCode.USER_ERROR)
150 return url
151
152
153 def _format_release(release: ReleaseRecord, output_json: bool) -> None:
154 """Print a release in text or JSON format.
155
156 All string fields are passed through ``sanitize_display`` before being
157 written to the terminal to prevent ANSI injection via crafted release
158 metadata (tag, channel, semver string, title, body, changelog messages).
159 """
160 if output_json:
161 print(json.dumps(release.to_dict(), default=str))
162 return
163 draft_label = " [DRAFT]" if release.is_draft else ""
164 print(f"Release {sanitize_display(release.tag)}{draft_label}")
165 print(f" Channel: {sanitize_display(release.channel)}")
166 print(f" Semver: {sanitize_display(semver_to_str(release.semver))}")
167 print(f" Commit: {release.commit_id[:8]}")
168 print(f" Created: {release.created_at.isoformat()}")
169 if release.title:
170 print(f" Title: {sanitize_display(release.title)}")
171 if release.body:
172 print(f" Body:\n{sanitize_display(release.body)}")
173 if release.changelog:
174 print(f" Changelog ({len(release.changelog)} commits):")
175 for entry in release.changelog[:20]:
176 bump = entry["sem_ver_bump"]
177 bump_label = {"major": "💥", "minor": "✨", "patch": "🔧"}.get(bump, " ")
178 print(
179 f" {bump_label} {entry['commit_id'][:8]} "
180 f"{sanitize_display(entry['message'][:72])}"
181 )
182 if len(release.changelog) > 20:
183 print(f" … and {len(release.changelog) - 20} more")
184
185
186 def _require_tty_or_yes(yes: bool, flag_name: str = "--yes") -> None:
187 """Exit with USER_ERROR if the process is non-interactive and --yes was not passed.
188
189 Agent pipelines run without a TTY. Any command that calls ``input()``
190 will block forever in that context. This guard makes the failure mode
191 explicit: the agent must pass ``--yes`` to skip interactive confirmation.
192 """
193 if not yes and not sys.stdin.isatty():
194 print(
195 f"❌ stdin is not a TTY — pass {flag_name} to skip interactive confirmation.",
196 file=sys.stderr,
197 )
198 raise SystemExit(ExitCode.USER_ERROR)
199
200
201 # ---------------------------------------------------------------------------
202 # Subcommand handlers
203 # ---------------------------------------------------------------------------
204
205
206 def run_add(args: argparse.Namespace) -> None:
207 """Create a local release at HEAD.
208
209 Parses ``<tag>`` as semver, auto-generates changelog from typed commit
210 metadata since the previous release, and writes the record to
211 ``.muse/releases/``.
212
213 The ``--ref`` flag lets you release any commit, not just the current HEAD.
214 Agents should pass ``--json`` to receive a stable, machine-readable payload
215 (full :class:`ReleaseRecord` serialised to JSON).
216
217 JSON output includes: ``tag``, ``channel``, ``commit_id``, ``snapshot_id``,
218 ``release_id``, ``is_draft``, ``changelog``, ``agent_id``, ``model_id``,
219 ``semver``, ``title``, ``body``, ``created_at``.
220
221 Exit codes:
222 0 — release created
223 1 — invalid semver; unknown channel; duplicate tag; ref not found
224 2 — not inside a Muse repository
225 """
226 tag: str = args.tag
227 title: str = args.title or ""
228 body: str = args.body or ""
229 channel_arg: str = args.channel or ""
230 is_draft: bool = args.draft
231 ref: str | None = args.ref
232 output_json: bool = args.output_json
233
234 try:
235 semver = parse_semver(tag)
236 except ValueError as exc:
237 print(f"❌ {exc}", file=sys.stderr)
238 raise SystemExit(ExitCode.USER_ERROR)
239
240 if channel_arg and channel_arg not in _CHANNELS:
241 print(
242 f"❌ Unknown channel '{sanitize_display(channel_arg)}'. "
243 f"Choose: {', '.join(sorted(_CHANNELS))}",
244 file=sys.stderr,
245 )
246 raise SystemExit(ExitCode.USER_ERROR)
247
248 channel: ReleaseChannel = _CHANNEL_MAP.get(channel_arg, semver_channel(semver))
249
250 root = require_repo()
251 repo_id = read_repo_id(root)
252 branch = read_current_branch(root)
253
254 commit = resolve_commit_ref(root, repo_id, branch, ref)
255 if commit is None:
256 ref_label = ref or "HEAD"
257 print(f"❌ Ref '{sanitize_display(ref_label)}' not found.", file=sys.stderr)
258 raise SystemExit(ExitCode.USER_ERROR)
259
260 if get_release_for_tag(root, repo_id, tag) is not None:
261 print(
262 f"❌ Release '{sanitize_display(tag)}' already exists. Delete it first.",
263 file=sys.stderr,
264 )
265 raise SystemExit(ExitCode.USER_ERROR)
266
267 existing = list_releases(root, repo_id, include_drafts=False)
268 prev_commit_id: str | None = existing[0].commit_id if existing else None
269
270 changelog = build_changelog(root, prev_commit_id, commit.commit_id)
271
272 release = ReleaseRecord(
273 release_id=str(uuid.uuid4()),
274 repo_id=repo_id,
275 tag=tag,
276 semver=semver,
277 channel=channel,
278 commit_id=commit.commit_id,
279 snapshot_id=commit.snapshot_id,
280 title=title,
281 body=body,
282 changelog=changelog,
283 agent_id=commit.agent_id,
284 model_id=commit.model_id,
285 is_draft=is_draft,
286 )
287 write_release(root, release)
288
289 if output_json:
290 # Emit the full record so agents get changelog, semver, provenance, etc.
291 print(json.dumps(release.to_dict(), default=str))
292 else:
293 draft_label = " (draft)" if is_draft else ""
294 print(
295 f"✅ Release {tag}{draft_label} — {len(changelog)} commits "
296 f"on branch {sanitize_display(branch)}"
297 )
298
299
300 def run_list(args: argparse.Namespace) -> None:
301 """List releases (local or remote).
302
303 With ``--json``, emits a JSON array of full ReleaseRecord objects.
304 Use ``--channel`` to filter by distribution channel, and ``--include-drafts``
305 to include unreleased drafts.
306
307 Exit codes:
308 0 — list returned (may be empty)
309 1 — remote not configured
310 2 — not inside a Muse repository
311 5 — remote communication error
312 """
313 channel_arg: str = args.channel or ""
314 include_drafts: bool = args.include_drafts
315 remote: str = args.remote or ""
316 output_json: bool = args.output_json
317
318 channel_filter: ReleaseChannel | None = _CHANNEL_MAP.get(channel_arg) if channel_arg else None
319
320 root = require_repo()
321
322 if remote:
323 url = _resolve_remote_url(root, remote)
324 token = get_signing_identity(root, url)
325 transport = make_transport(url)
326 try:
327 raw_releases = transport.list_releases_remote(
328 url, token,
329 channel=channel_filter,
330 include_drafts=include_drafts,
331 )
332 except TransportError as exc:
333 print(
334 f"❌ Could not fetch releases from remote: {sanitize_display(str(exc))}",
335 file=sys.stderr,
336 )
337 raise SystemExit(ExitCode.REMOTE_ERROR)
338
339 releases = [ReleaseRecord.from_dict(d) for d in raw_releases]
340 else:
341 repo_id = read_repo_id(root)
342 releases = list_releases(root, repo_id, channel=channel_filter, include_drafts=include_drafts)
343
344 if output_json:
345 print(json.dumps([r.to_dict() for r in releases], default=str))
346 return
347
348 if not releases:
349 print("No releases found.")
350 return
351
352 for r in releases:
353 draft_label = " [DRAFT]" if r.is_draft else ""
354 print(
355 f"{sanitize_display(r.tag):<20} {sanitize_display(r.channel):<8} "
356 f"{r.commit_id[:8]} "
357 f"{sanitize_display(r.title)[:40]}{draft_label}"
358 )
359
360
361 def run_show(args: argparse.Namespace) -> None:
362 """Show details of a single release.
363
364 With ``--json``, emits the full :class:`ReleaseRecord` as a JSON object
365 including the changelog, semver components, agent provenance, and
366 snapshot ID.
367
368 JSON output includes: ``tag``, ``channel``, ``commit_id``, ``snapshot_id``,
369 ``release_id``, ``is_draft``, ``changelog``, ``semver``, ``title``,
370 ``body``, ``agent_id``, ``model_id``, ``created_at``.
371
372 Exit codes:
373 0 — release shown
374 2 — not inside a Muse repository
375 4 — release tag not found
376 """
377 tag: str = args.tag
378 output_json: bool = args.output_json
379
380 root = require_repo()
381 repo_id = read_repo_id(root)
382
383 release = get_release_for_tag(root, repo_id, tag)
384 if release is None:
385 print(f"❌ Release '{sanitize_display(tag)}' not found.", file=sys.stderr)
386 raise SystemExit(ExitCode.NOT_FOUND)
387
388 _format_release(release, output_json)
389
390
391 def run_push(args: argparse.Namespace) -> None:
392 """Push a local release to a remote.
393
394 Transmits the lightweight ``ReleaseRecord`` payload to MuseHub, which then
395 runs the full semantic analysis (language breakdown, symbol inventory, API
396 surface diff, file hotspots, refactoring events, provenance) as a server-
397 side background task. The push completes immediately; the enriched release
398 detail page populates within seconds.
399
400 Use ``--dry-run`` to validate the release record and remote configuration
401 without actually transmitting anything.
402
403 JSON output fields (``--json``)
404 --------------------------------
405 ``status``
406 ``"pushed"`` on success; ``"dry_run"`` when ``--dry-run`` was passed.
407 ``tag``
408 The version tag that was pushed (e.g. ``"v1.2.0"``).
409 ``remote``
410 The named remote used (e.g. ``"origin"``).
411 ``release_id``
412 UUID assigned by the remote hub after a real push; local release ID
413 during dry-run.
414 ``dry_run``
415 ``true`` if ``--dry-run`` was passed, else ``false``.
416
417 Exit codes
418 ----------
419 0
420 Release pushed successfully (or dry-run validated).
421 1
422 Remote not configured.
423 2
424 Not inside a Muse repository.
425 4
426 Tag not found locally — run ``muse release add`` first.
427 5
428 Remote communication error (network failure, auth, server error).
429 """
430 tag: str = args.tag
431 remote: str = args.remote
432 dry_run: bool = args.dry_run
433 output_json: bool = args.output_json
434
435 root = require_repo()
436 repo_id = read_repo_id(root)
437
438 release = get_release_for_tag(root, repo_id, tag)
439 if release is None:
440 print(
441 f"❌ Release '{sanitize_display(tag)}' not found locally. "
442 "Run 'muse release add' first.",
443 file=sys.stderr,
444 )
445 raise SystemExit(ExitCode.NOT_FOUND)
446
447 if dry_run:
448 # Skip remote URL lookup — no network call is made in dry-run mode.
449 if output_json:
450 push_payload = _ReleasePushJson(
451 status="dry_run",
452 tag=tag,
453 remote=remote,
454 release_id=release.release_id,
455 dry_run=True,
456 )
457 print(json.dumps(push_payload))
458 else:
459 print(
460 f"Would push release {sanitize_display(tag)} to {sanitize_display(remote)} "
461 f"(id={release.release_id[:8]}, dry-run)"
462 )
463 return
464
465 url = _resolve_remote_url(root, remote)
466 token = get_signing_identity(root, url)
467 transport = make_transport(url)
468
469 try:
470 release_id = transport.create_release(url, token, release.to_dict())
471 except TransportError as exc:
472 print(f"❌ Push failed: {sanitize_display(str(exc))}", file=sys.stderr)
473 raise SystemExit(ExitCode.REMOTE_ERROR)
474
475 if output_json:
476 push_payload = _ReleasePushJson(
477 status="pushed",
478 tag=tag,
479 remote=remote,
480 release_id=release_id,
481 dry_run=False,
482 )
483 print(json.dumps(push_payload))
484 else:
485 print(f"✅ Release {sanitize_display(tag)} pushed to {sanitize_display(remote)} (id={release_id[:8]})")
486
487
488 def run_delete(args: argparse.Namespace) -> None:
489 """Delete a release label locally and optionally retract it from a remote.
490
491 Deletion removes only the named pointer — the underlying commit and
492 snapshot remain in the content-addressed object store forever. They
493 are still fully reproducible by their SHA-256; only the label is gone.
494
495 Published releases require explicit confirmation (type the tag name) so
496 accidental retractions of stable releases are hard to do silently.
497
498 Non-interactive contexts (no TTY, agent pipelines) must pass ``--yes``
499 to skip the confirmation prompt. Without it, the command exits with
500 USER_ERROR rather than blocking on ``input()``.
501
502 Use ``--dry-run`` to inspect what would be deleted without deleting it.
503
504 JSON output fields (``--json``)
505 --------------------------------
506 ``status``
507 ``"deleted"`` on success; ``"aborted"`` when the user declined
508 interactive confirmation; ``"dry_run"`` when ``--dry-run`` was passed.
509 ``tag``
510 The version tag that was (or would be) deleted.
511 ``was_draft``
512 ``true`` if the release was a draft at deletion time.
513 ``remote_retracted``
514 ``true`` if ``--remote`` was supplied and the remote retraction
515 succeeded. Always ``false`` in dry-run and aborted cases.
516 ``dry_run``
517 ``true`` if ``--dry-run`` was passed, else ``false``.
518
519 Exit codes
520 ----------
521 0
522 Release deleted (or dry-run validated, or user aborted interactively).
523 1
524 Non-TTY context without ``--yes``; or remote not configured.
525 2
526 Not inside a Muse repository.
527 4
528 Tag not found locally.
529 5
530 Remote retraction failed (network failure, auth, server error).
531 """
532 tag: str = args.tag
533 yes: bool = args.yes
534 remote: str = args.remote or ""
535 dry_run: bool = args.dry_run
536 output_json: bool = args.output_json
537
538 root = require_repo()
539 repo_id = read_repo_id(root)
540
541 release = get_release_for_tag(root, repo_id, tag)
542 if release is None:
543 print(f"❌ Release '{sanitize_display(tag)}' not found locally.", file=sys.stderr)
544 raise SystemExit(ExitCode.NOT_FOUND)
545
546 if dry_run:
547 if output_json:
548 del_payload = _ReleaseDeleteJson(
549 status="dry_run",
550 tag=tag,
551 was_draft=release.is_draft,
552 remote_retracted=False,
553 dry_run=True,
554 )
555 print(json.dumps(del_payload))
556 else:
557 remote_label = f" and retract from {sanitize_display(remote)}" if remote else ""
558 print(
559 f"Would delete release {sanitize_display(tag)}{remote_label} "
560 f"({'draft' if release.is_draft else 'published'}, dry-run)"
561 )
562 return
563
564 # Guard: non-TTY context must pass --yes.
565 _require_tty_or_yes(yes)
566
567 # Interactive confirmation.
568 if not release.is_draft and not yes:
569 print(
570 f"⚠️ '{sanitize_display(tag)}' is a published release. "
571 "Deleting it removes the label; the underlying commit is unaffected."
572 )
573 answer = input("Type the tag name to confirm deletion: ").strip()
574 if answer != tag:
575 if output_json:
576 del_payload = _ReleaseDeleteJson(
577 status="aborted",
578 tag=tag,
579 was_draft=False,
580 remote_retracted=False,
581 dry_run=False,
582 )
583 print(json.dumps(del_payload))
584 else:
585 print("Aborted.")
586 return
587 elif release.is_draft and not yes:
588 answer = input(f"Delete draft release '{sanitize_display(tag)}'? [y/N] ").strip().lower()
589 if answer not in ("y", "yes"):
590 if output_json:
591 del_payload = _ReleaseDeleteJson(
592 status="aborted",
593 tag=tag,
594 was_draft=True,
595 remote_retracted=False,
596 dry_run=False,
597 )
598 print(json.dumps(del_payload))
599 else:
600 print("Aborted.")
601 return
602
603 # Retract from remote first so a local-only failure doesn't leave the
604 # local record orphaned relative to the remote.
605 remote_retracted = False
606 if remote:
607 url = _resolve_remote_url(root, remote)
608 token = get_signing_identity(root, url)
609 transport = make_transport(url)
610 try:
611 transport.delete_release_remote(url, token, tag)
612 remote_retracted = True
613 except TransportError as exc:
614 print(
615 f"❌ Remote retraction failed: {sanitize_display(str(exc))}",
616 file=sys.stderr,
617 )
618 raise SystemExit(ExitCode.REMOTE_ERROR)
619 if not output_json:
620 print(f"✅ Release {sanitize_display(tag)} retracted from {sanitize_display(remote)}.")
621
622 deleted = delete_release(root, repo_id, release.release_id)
623 if deleted:
624 if output_json:
625 del_payload = _ReleaseDeleteJson(
626 status="deleted",
627 tag=tag,
628 was_draft=release.is_draft,
629 remote_retracted=remote_retracted,
630 dry_run=False,
631 )
632 print(json.dumps(del_payload))
633 else:
634 print(f"✅ Release {sanitize_display(tag)} deleted locally.")
635 else:
636 print(
637 f"❌ Local release '{sanitize_display(tag)}' could not be deleted.",
638 file=sys.stderr,
639 )
640 raise SystemExit(ExitCode.USER_ERROR)
641
642
643 _BUMP_ORDER: list[SemVerBump] = ["none", "patch", "minor", "major"]
644
645
646 def _bump_rank(bump: SemVerBump) -> int:
647 try:
648 return _BUMP_ORDER.index(bump)
649 except ValueError:
650 return 0
651
652
653 def run_suggest(args: argparse.Namespace) -> None:
654 """Infer the next version tag from the commit graph.
655
656 Walks every commit since the last release (or since the initial commit if
657 no releases exist) and aggregates the ``sem_ver_bump`` values that Muse
658 recorded at commit time — derived from structural diffs of the public
659 symbol graph, not from commit message conventions.
660
661 The highest bump seen across all unreleased commits drives the version
662 arithmetic:
663
664 * **Pre-1.0 repos** (``major == 0``): a ``major`` structural break bumps
665 the *minor* component (``0.x+1.0``) rather than crossing the 1.0
666 boundary. A ``minor`` or ``patch`` bump increments patch (``0.x.y+1``).
667 This matches the semver spec for unstable APIs.
668
669 * **1.0+ repos**: standard semver — major/minor/patch bumps their
670 respective components.
671
672 If no commits carry a meaningful bump (all are ``"none"``), no suggestion
673 is made and the command exits 0 with an explicit message.
674
675 Options:
676 --base <tag> Start from this release tag instead of the latest.
677 --ref <commit> Treat this commit as HEAD instead of the branch tip.
678 --json Machine-readable output.
679
680 JSON output schema::
681
682 {
683 "suggested_tag": "v0.3.0", // null when bump is "none"
684 "inferred_bump": "major", // max sem_ver_bump across commits
685 "pre_1_0_adjusted": true, // true when 0.x rules applied
686 "base_tag": "v0.2.0", // null when no prior release
687 "base_commit_id": "<hex64>", // null when no prior release
688 "head_commit_id": "<hex64>",
689 "unreleased_count": 7,
690 "drivers": [ // commits that raised the bump
691 {
692 "commit_id": "<hex64>",
693 "message": "feat: ...",
694 "sem_ver_bump": "major",
695 "breaking_changes": ["path/file.py::Symbol"]
696 }
697 ]
698 }
699
700 Exit codes:
701 0 — suggestion produced (or no bump needed)
702 2 — not inside a Muse repository
703 4 — --base tag not found
704 """
705 output_json: bool = args.output_json
706 base_tag: str = args.base or ""
707 ref: str | None = args.ref
708
709 root = require_repo()
710 repo_id = read_repo_id(root)
711 branch = read_current_branch(root)
712
713 # Resolve base release.
714 if base_tag:
715 base_release = get_release_for_tag(root, repo_id, base_tag)
716 if base_release is None:
717 print(f"❌ Base release '{sanitize_display(base_tag)}' not found.", file=sys.stderr)
718 raise SystemExit(ExitCode.NOT_FOUND)
719 else:
720 all_releases = list_releases(root, repo_id, include_drafts=False)
721 base_release = all_releases[0] if all_releases else None # newest first
722
723 # Resolve HEAD.
724 if ref:
725 head_commit = resolve_commit_ref(root, repo_id, branch, ref)
726 if head_commit is None:
727 print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr)
728 raise SystemExit(ExitCode.NOT_FOUND)
729 head_id = head_commit.commit_id
730 else:
731 head_id = get_head_commit_id(root, branch) or ""
732 if not head_id:
733 print("❌ No commits on this branch yet.", file=sys.stderr)
734 raise SystemExit(ExitCode.USER_ERROR)
735
736 from_commit_id: str | None = base_release.commit_id if base_release else None
737
738 # Walk unreleased commits (newest first).
739 commits = walk_commits_between(root, head_id, from_commit_id)
740
741 # Aggregate highest bump and collect driver commits.
742 agg_bump: SemVerBump = "none"
743 drivers: list[dict[str, object]] = []
744 for commit in commits:
745 cb: SemVerBump = commit.sem_ver_bump # type: ignore[assignment]
746 if _bump_rank(cb) > _bump_rank(agg_bump):
747 agg_bump = cb
748 if cb != "none":
749 drivers.append({
750 "commit_id": commit.commit_id,
751 "message": commit.message,
752 "sem_ver_bump": cb,
753 "breaking_changes": list(commit.breaking_changes),
754 })
755
756 # Parse base semver.
757 if base_release and base_release.semver:
758 sv = base_release.semver
759 major = int(sv.get("major") or 0)
760 minor = int(sv.get("minor") or 0)
761 patch = int(sv.get("patch") or 0)
762 else:
763 major, minor, patch = 0, 0, 0
764
765 # Compute next version.
766 suggested_tag: str | None = None
767 pre_1_0_adjusted = False
768 if agg_bump == "none":
769 suggested_tag = None
770 elif major == 0:
771 pre_1_0_adjusted = True
772 if agg_bump == "major":
773 suggested_tag = f"v0.{minor + 1}.0"
774 else: # minor or patch → bump patch in 0.x.y
775 suggested_tag = f"v0.{minor}.{patch + 1}"
776 else:
777 if agg_bump == "major":
778 suggested_tag = f"v{major + 1}.0.0"
779 elif agg_bump == "minor":
780 suggested_tag = f"v{major}.{minor + 1}.0"
781 else:
782 suggested_tag = f"v{major}.{minor}.{patch + 1}"
783
784 payload = {
785 "suggested_tag": suggested_tag,
786 "inferred_bump": agg_bump,
787 "pre_1_0_adjusted": pre_1_0_adjusted,
788 "base_tag": base_release.tag if base_release else None,
789 "base_commit_id": base_release.commit_id if base_release else None,
790 "head_commit_id": head_id,
791 "unreleased_count": len(commits),
792 "drivers": drivers,
793 }
794
795 if output_json:
796 print(json.dumps(payload, default=str))
797 return
798
799 base_label = base_release.tag if base_release else "(no prior release)"
800 if suggested_tag is None:
801 print(
802 f"No version bump required since {sanitize_display(base_label)}. "
803 f"All {len(commits)} unreleased commit(s) carry bump=\"none\"."
804 )
805 return
806
807 adj_note = " (pre-1.0 adjusted)" if pre_1_0_adjusted else ""
808 print(
809 f"Suggested next release: {suggested_tag}{adj_note}\n"
810 f" Inferred bump : {agg_bump}\n"
811 f" Base : {sanitize_display(base_label)}\n"
812 f" Unreleased : {len(commits)} commit(s)\n"
813 f" Drivers : {len(drivers)} commit(s) with meaningful bump"
814 )
815 for d in drivers:
816 bump_label = {"major": "💥", "minor": "✨", "patch": "🔧"}.get(str(d["sem_ver_bump"]), " ")
817 bc = d["breaking_changes"]
818 bc_str = f" — breaks: {', '.join(str(s) for s in bc[:3])}" if bc else "" # type: ignore[arg-type]
819 print(
820 f" {bump_label} {str(d['commit_id'])[:8]} "
821 f"{sanitize_display(str(d['message'])[:60])}{bc_str}"
822 )
823
824
825 # ---------------------------------------------------------------------------
826 # Command registration
827 # ---------------------------------------------------------------------------
828
829
830 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
831 """Register the ``muse release`` subcommand tree."""
832 parser = subparsers.add_parser(
833 "release",
834 help="Create and manage versioned releases.",
835 description=__doc__,
836 formatter_class=argparse.RawDescriptionHelpFormatter,
837 )
838 # Top-level flags for the default (no subcommand → list) behaviour.
839 # These mirror the `list` subparser flags so `muse release --json` works
840 # identically to `muse release list --json`.
841 parser.add_argument(
842 "--json", "-j", action="store_true", dest="output_json",
843 help="Emit machine-readable JSON on stdout (default action: list).",
844 )
845 parser.add_argument(
846 "--channel", default="", metavar="CHANNEL",
847 help="Filter by channel when listing (stable, beta, alpha, nightly).",
848 )
849 parser.add_argument(
850 "--include-drafts", action="store_true",
851 help="Include draft releases when listing.",
852 )
853 parser.add_argument(
854 "--remote", default="",
855 help="Fetch from this remote when listing (e.g. origin).",
856 )
857
858 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
859 subs.required = False
860 # Default: bare `muse release` (no subcommand) lists releases, matching
861 # the ergonomics of `muse branch`, `muse tag`, etc.
862 parser.set_defaults(func=run_list)
863
864 # --- add ---
865 add_p = subs.add_parser(
866 "add",
867 help="Create a local release at HEAD.",
868 description=(
869 "Parse ``<tag>`` as semver, auto-generate a changelog from typed\n"
870 "commit metadata since the previous release, and write the record\n"
871 "to ``.muse/releases/``.\n\n"
872 "The channel is inferred from the semver pre-release label\n"
873 "(``-beta.*`` → beta, ``-alpha.*`` → alpha, ``-nightly.*`` → nightly,\n"
874 "no pre-release → stable) or overridden with ``--channel``.\n\n"
875 "Agent quickstart\n"
876 "----------------\n"
877 " muse release add v1.2.0 --json\n"
878 " muse release add v1.3.0-beta.1 --channel beta --draft --json\n\n"
879 "JSON output schema\n"
880 "------------------\n"
881 " Full ReleaseRecord — key fields:\n"
882 ' {"tag": "<str>", "channel": "<str>", "commit_id": "<hex64>",\n'
883 ' "snapshot_id": "<hex64>", "release_id": "<uuid>",\n'
884 ' "is_draft": <bool>, "changelog": [...]}\n\n'
885 "Exit codes\n"
886 "----------\n"
887 " 0 — release created\n"
888 " 1 — invalid semver; unknown channel; duplicate tag; ref not found\n"
889 " 2 — not inside a Muse repository\n"
890 ),
891 formatter_class=argparse.RawDescriptionHelpFormatter,
892 )
893 add_p.add_argument("tag", help="Version tag (semver, e.g. v1.2.0 or v1.3.0-beta.1).")
894 add_p.add_argument("--title", default="", help="Release title.")
895 add_p.add_argument("--body", default="", help="Release body / description.")
896 add_p.add_argument(
897 "--channel",
898 default="",
899 choices=sorted(_CHANNELS),
900 help="Distribution channel (default: inferred from semver pre-release label).",
901 )
902 add_p.add_argument("--draft", action="store_true", help="Mark release as a draft.")
903 add_p.add_argument(
904 "--ref", "--commit", default=None,
905 help="Commit ID or branch to release (default: HEAD).",
906 )
907 add_p.add_argument(
908 "--json", "-j", action="store_true", dest="output_json",
909 help="Emit machine-readable JSON on stdout.",
910 )
911 add_p.set_defaults(func=run_add)
912
913 # --- delete ---
914 del_p = subs.add_parser(
915 "delete",
916 help="Delete a release label locally and optionally retract it from a remote.",
917 description=(
918 "Remove a release label. The underlying commit and snapshot remain\n"
919 "in the content-addressed object store forever — only the named\n"
920 "pointer is deleted. Published releases require typed confirmation\n"
921 "of the tag name; drafts require y/N confirmation.\n\n"
922 "In non-interactive (agent) contexts pass ``--yes`` to skip all\n"
923 "prompts — without it the command exits with USER_ERROR rather\n"
924 "than blocking on stdin.\n\n"
925 "Use ``--remote`` to also retract the release from a named remote.\n"
926 "The remote retraction is attempted first; local deletion follows\n"
927 "only if the remote call succeeds.\n\n"
928 "Agent quickstart\n"
929 "----------------\n"
930 " muse release delete v1.2.0 --yes --json\n"
931 " muse release delete v1.2.0 --yes --remote origin --json\n"
932 " muse release delete v1.2.0 --dry-run --json\n\n"
933 "JSON output schema\n"
934 "------------------\n"
935 ' {"status": "deleted"|"aborted"|"dry_run",\n'
936 ' "tag": "<str>", "was_draft": <bool>,\n'
937 ' "remote_retracted": <bool>, "dry_run": <bool>}\n\n'
938 "Exit codes\n"
939 "----------\n"
940 " 0 — deleted (or dry-run validated, or user aborted interactively)\n"
941 " 1 — non-TTY without --yes; or remote not configured\n"
942 " 2 — not inside a Muse repository\n"
943 " 4 — tag not found locally\n"
944 " 5 — remote retraction failed\n"
945 ),
946 formatter_class=argparse.RawDescriptionHelpFormatter,
947 )
948 del_p.add_argument("tag", help="Version tag to delete (e.g. v1.2.0).")
949 del_p.add_argument("--remote", default="", help="Also retract from this remote (e.g. origin).")
950 del_p.add_argument(
951 "--yes", "-y", action="store_true",
952 help="Skip confirmation. Required in non-TTY (agent) contexts.",
953 )
954 del_p.add_argument(
955 "-n", "--dry-run", action="store_true", dest="dry_run",
956 help="Show what would be deleted without deleting.",
957 )
958 del_p.add_argument(
959 "--json", "-j", action="store_true", dest="output_json",
960 help="Emit machine-readable JSON on stdout.",
961 )
962 del_p.set_defaults(func=run_delete)
963
964 # --- list ---
965 list_p = subs.add_parser(
966 "list",
967 help="List releases.",
968 description=(
969 "List local releases, optionally filtered by channel or including\n"
970 "drafts. With ``--remote``, fetches from the named remote instead.\n\n"
971 "Agent quickstart\n"
972 "----------------\n"
973 " muse release list --json\n"
974 " muse release list --channel stable --json\n"
975 " muse release list --include-drafts --json\n\n"
976 "JSON output schema\n"
977 "------------------\n"
978 " Array of full ReleaseRecord objects — key fields per entry:\n"
979 ' [{"tag": "<str>", "channel": "<str>", "commit_id": "<hex64>",\n'
980 ' "snapshot_id": "<hex64>", "release_id": "<uuid>",\n'
981 ' "is_draft": <bool>, "changelog": [...]}, ...]\n\n'
982 "Exit codes\n"
983 "----------\n"
984 " 0 — list returned (may be empty)\n"
985 " 1 — remote not configured\n"
986 " 2 — not inside a Muse repository\n"
987 " 5 — remote communication error\n"
988 ),
989 formatter_class=argparse.RawDescriptionHelpFormatter,
990 )
991 list_p.add_argument(
992 "--channel",
993 default="",
994 choices=list(sorted(_CHANNELS)) + [""],
995 metavar="CHANNEL",
996 help=f"Filter by channel: {', '.join(sorted(_CHANNELS))}.",
997 )
998 list_p.add_argument("--include-drafts", action="store_true", help="Show draft releases.")
999 list_p.add_argument("--remote", default="", help="Fetch from this remote (e.g. origin).")
1000 list_p.add_argument(
1001 "--json", "-j", action="store_true", dest="output_json",
1002 help="Emit machine-readable JSON on stdout.",
1003 )
1004 list_p.set_defaults(func=run_list)
1005
1006 # --- push ---
1007 push_p = subs.add_parser(
1008 "push",
1009 help="Push a release to a remote.",
1010 description=(
1011 "Transmit a local release record to MuseHub. The remote runs full\n"
1012 "semantic analysis (language breakdown, symbol inventory, API surface\n"
1013 "diff, file hotspots) as a background task — the push completes\n"
1014 "immediately and the enriched detail page populates within seconds.\n\n"
1015 "Use ``--dry-run`` to validate without transmitting anything.\n\n"
1016 "Agent quickstart\n"
1017 "----------------\n"
1018 " muse release push v1.2.0 --json\n"
1019 " muse release push v1.2.0 --dry-run --json\n"
1020 " muse release push v1.2.0 --remote staging --json\n\n"
1021 "JSON output schema\n"
1022 "------------------\n"
1023 ' {"status": "pushed"|"dry_run", "tag": "<str>",\n'
1024 ' "remote": "<str>", "release_id": "<uuid>", "dry_run": <bool>}\n\n'
1025 "Exit codes\n"
1026 "----------\n"
1027 " 0 — pushed (or dry-run validated)\n"
1028 " 1 — remote not configured\n"
1029 " 2 — not inside a Muse repository\n"
1030 " 4 — tag not found locally (run muse release add first)\n"
1031 " 5 — remote communication error\n"
1032 ),
1033 formatter_class=argparse.RawDescriptionHelpFormatter,
1034 )
1035 push_p.add_argument("tag", help="Version tag to push (e.g. v1.2.0).")
1036 push_p.add_argument("--remote", default="origin", help="Remote name (default: origin).")
1037 push_p.add_argument(
1038 "-n", "--dry-run", action="store_true", dest="dry_run",
1039 help="Validate without transmitting.",
1040 )
1041 push_p.add_argument(
1042 "--json", "-j", action="store_true", dest="output_json",
1043 help="Emit machine-readable JSON on stdout.",
1044 )
1045 push_p.set_defaults(func=run_push)
1046
1047 # --- suggest ---
1048 suggest_p = subs.add_parser(
1049 "suggest",
1050 help="Infer the next version tag from the commit graph.",
1051 description=(
1052 "Derive the next semantic version by aggregating the ``sem_ver_bump``\n"
1053 "values Muse recorded on each commit since the last release. Those\n"
1054 "values are structural — they come from diffing the public symbol\n"
1055 "graph at commit time, not from parsing commit messages.\n\n"
1056 "The highest bump seen across unreleased commits drives version\n"
1057 "arithmetic. Pre-1.0 repos (major == 0) apply semver unstable-API\n"
1058 "rules: a major structural break bumps the minor component instead\n"
1059 "of crossing the 1.0 boundary.\n\n"
1060 "Agent quickstart\n"
1061 "----------------\n"
1062 " muse release suggest --json\n"
1063 " muse release suggest --base v1.1.0 --json\n\n"
1064 "JSON output schema\n"
1065 "------------------\n"
1066 ' {"suggested_tag": "v0.3.0", // null when bump is "none"\n'
1067 ' "inferred_bump": "major", // max sem_ver_bump across commits\n'
1068 ' "pre_1_0_adjusted": true, // pre-1.0 minor-bump rule applied\n'
1069 ' "base_tag": "v0.2.0", // null when no prior release\n'
1070 ' "base_commit_id": "<hex64>", // null when no prior release\n'
1071 ' "head_commit_id": "<hex64>",\n'
1072 ' "unreleased_count": 7,\n'
1073 ' "drivers": [{"commit_id": ..., "message": ...,\n'
1074 ' "sem_ver_bump": ..., "breaking_changes": [...]}]}\n\n'
1075 "Exit codes\n"
1076 "----------\n"
1077 " 0 — suggestion produced (or no bump needed)\n"
1078 " 2 — not inside a Muse repository\n"
1079 " 4 — --base tag not found\n"
1080 ),
1081 formatter_class=argparse.RawDescriptionHelpFormatter,
1082 )
1083 suggest_p.add_argument(
1084 "--base", default="", metavar="TAG",
1085 help="Start from this release tag instead of the latest.",
1086 )
1087 suggest_p.add_argument(
1088 "--ref", "--commit", default=None,
1089 help="Treat this commit as HEAD instead of the branch tip.",
1090 )
1091 suggest_p.add_argument(
1092 "--json", "-j", action="store_true", dest="output_json",
1093 help="Emit machine-readable JSON on stdout.",
1094 )
1095 suggest_p.set_defaults(func=run_suggest)
1096
1097 # --- show ---
1098 show_p = subs.add_parser(
1099 "show",
1100 help="Inspect a single release.",
1101 description=(
1102 "Display full details of one release: semver, channel, commit,\n"
1103 "snapshot, changelog, agent provenance, and draft status.\n\n"
1104 "Agent quickstart\n"
1105 "----------------\n"
1106 " muse release show v1.2.0 --json\n\n"
1107 "JSON output schema\n"
1108 "------------------\n"
1109 " Full ReleaseRecord — key fields:\n"
1110 ' {"tag": "<str>", "channel": "<str>", "commit_id": "<hex64>",\n'
1111 ' "snapshot_id": "<hex64>", "release_id": "<uuid>",\n'
1112 ' "is_draft": <bool>, "changelog": [...],\n'
1113 ' "semver": {...}, "title": "<str>", "body": "<str>",\n'
1114 ' "agent_id": "<str>", "model_id": "<str>",\n'
1115 ' "created_at": "<iso8601>"}\n\n'
1116 "Exit codes\n"
1117 "----------\n"
1118 " 0 — release shown\n"
1119 " 2 — not inside a Muse repository\n"
1120 " 4 — release tag not found\n"
1121 ),
1122 formatter_class=argparse.RawDescriptionHelpFormatter,
1123 )
1124 show_p.add_argument("tag", help="Version tag (e.g. v1.2.0).")
1125 show_p.add_argument(
1126 "--json", "-j", action="store_true", dest="output_json",
1127 help="Emit machine-readable JSON on stdout.",
1128 )
1129 show_p.set_defaults(func=run_show)
File History 4 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago