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