release.py
python
| 1 | """muse release <tag> — export and render a tagged commit as a release artifact. |
| 2 | |
| 3 | This is the music-native publish step: given a tag applied via ``muse tag add``, |
| 4 | it resolves the tagged commit, fetches its MIDI snapshot, and renders it to |
| 5 | audio/MIDI artifacts ready for distribution. |
| 6 | |
| 7 | Usage:: |
| 8 | |
| 9 | muse release v1.0 # manifest only (dry run) |
| 10 | muse release v1.0 --render-audio # single WAV file |
| 11 | muse release v1.0 --render-midi # zip of all MIDI files |
| 12 | muse release v1.0 --export-stems --format flac # per-track FLAC stems |
| 13 | muse release v1.0 --render-audio --render-midi \\ |
| 14 | --output-dir ./dist/v1.0 # custom output dir |
| 15 | |
| 16 | Flags: |
| 17 | <tag> Music-semantic tag created via ``muse tag add``. |
| 18 | --render-audio Render all MIDI to a single audio file via Storpheus. |
| 19 | --render-midi Bundle all .mid files into a zip archive. |
| 20 | --export-stems Export each track as a separate audio file. |
| 21 | --format Audio output format: wav | mp3 | flac (default: wav). |
| 22 | --output-dir PATH Where to write artifacts (default: ./releases/<tag>/). |
| 23 | --json Emit structured JSON output for agent consumption. |
| 24 | |
| 25 | Output layout:: |
| 26 | |
| 27 | <output-dir>/ |
| 28 | release-manifest.json # always written; SHA-256 checksums |
| 29 | audio/<commit8>.<format> # --render-audio |
| 30 | midi/midi-bundle.zip # --render-midi |
| 31 | stems/<stem>.<format> # --export-stems |
| 32 | |
| 33 | This command resolves the tag via the Muse tag database (``muse tag add``). |
| 34 | If multiple commits share the same tag the most recently committed one is used. |
| 35 | """ |
| 36 | from __future__ import annotations |
| 37 | |
| 38 | import asyncio |
| 39 | import json as json_mod |
| 40 | import logging |
| 41 | import pathlib |
| 42 | from typing import Optional |
| 43 | |
| 44 | import typer |
| 45 | from sqlalchemy import select |
| 46 | from sqlalchemy.ext.asyncio import AsyncSession |
| 47 | |
| 48 | from maestro.config import settings |
| 49 | from maestro.muse_cli._repo import require_repo |
| 50 | from maestro.muse_cli.db import find_commits_by_prefix, get_commit_snapshot_manifest, open_session |
| 51 | from maestro.muse_cli.errors import ExitCode |
| 52 | from maestro.muse_cli.models import MuseCliCommit, MuseCliTag |
| 53 | from maestro.services.muse_release import ( |
| 54 | ReleaseAudioFormat, |
| 55 | ReleaseResult, |
| 56 | StorpheusReleaseUnavailableError, |
| 57 | build_release, |
| 58 | ) |
| 59 | |
| 60 | logger = logging.getLogger(__name__) |
| 61 | |
| 62 | app = typer.Typer() |
| 63 | |
| 64 | _DEFAULT_RELEASES_DIR = "releases" |
| 65 | |
| 66 | |
| 67 | def _default_output_dir(tag: str) -> pathlib.Path: |
| 68 | """Return the default output directory for a release. |
| 69 | |
| 70 | Pattern: ``./releases/<tag>/``. Safe for any tag string that is a valid |
| 71 | directory name — callers should sanitise the tag before passing here. |
| 72 | |
| 73 | Args: |
| 74 | tag: Release tag string (e.g. ``"v1.0"``). |
| 75 | |
| 76 | Returns: |
| 77 | A Path relative to the current working directory. |
| 78 | """ |
| 79 | safe_tag = tag.replace("/", "_").replace("\\", "_") |
| 80 | return pathlib.Path(_DEFAULT_RELEASES_DIR) / safe_tag |
| 81 | |
| 82 | |
| 83 | async def _resolve_tag_to_commit( |
| 84 | session: AsyncSession, |
| 85 | root: pathlib.Path, |
| 86 | tag: str, |
| 87 | ) -> str: |
| 88 | """Resolve a music-semantic tag string to a full commit ID. |
| 89 | |
| 90 | Queries the ``muse_cli_tags`` table for commits carrying *tag*. When |
| 91 | multiple commits share the tag, the most recently committed one is returned |
| 92 | — this matches the producer's expectation that ``v1.0`` refers to the |
| 93 | latest commit labelled with that tag. |
| 94 | |
| 95 | Falls back to prefix-based commit lookup when no tag record is found, so |
| 96 | producers can also pass a raw commit SHA prefix directly. |
| 97 | |
| 98 | Args: |
| 99 | session: Open async DB session. |
| 100 | root: Muse repository root (used to read repo.json). |
| 101 | tag: Tag string (e.g. ``"v1.0"``) or short commit SHA prefix. |
| 102 | |
| 103 | Returns: |
| 104 | Full 64-character commit ID. |
| 105 | |
| 106 | Raises: |
| 107 | typer.Exit: With ``USER_ERROR`` when the tag/prefix cannot be resolved. |
| 108 | """ |
| 109 | import json |
| 110 | |
| 111 | # Read repo_id for scoped tag lookup. |
| 112 | repo_json = root / ".muse" / "repo.json" |
| 113 | repo_id: str | None = None |
| 114 | if repo_json.exists(): |
| 115 | data: dict[str, str] = json.loads(repo_json.read_text()) |
| 116 | repo_id = data.get("repo_id") |
| 117 | |
| 118 | # 1. Tag-based lookup (join MuseCliTag → MuseCliCommit). |
| 119 | if repo_id is not None: |
| 120 | tag_result = await session.execute( |
| 121 | select(MuseCliTag.commit_id) |
| 122 | .where(MuseCliTag.repo_id == repo_id, MuseCliTag.tag == tag) |
| 123 | ) |
| 124 | tag_commit_ids: list[str] = list(tag_result.scalars().all()) |
| 125 | |
| 126 | if tag_commit_ids: |
| 127 | if len(tag_commit_ids) == 1: |
| 128 | return tag_commit_ids[0] |
| 129 | |
| 130 | # Multiple commits share the tag — return the most recently committed. |
| 131 | commits_result = await session.execute( |
| 132 | select(MuseCliCommit) |
| 133 | .where(MuseCliCommit.commit_id.in_(tag_commit_ids)) |
| 134 | .order_by(MuseCliCommit.committed_at.desc()) |
| 135 | .limit(1) |
| 136 | ) |
| 137 | latest = commits_result.scalar_one_or_none() |
| 138 | if latest is not None: |
| 139 | logger.warning( |
| 140 | "⚠️ release: tag %r exists on %d commits — using most recent: %s", |
| 141 | tag, |
| 142 | len(tag_commit_ids), |
| 143 | latest.commit_id[:8], |
| 144 | ) |
| 145 | return latest.commit_id |
| 146 | |
| 147 | # 2. Prefix-based fallback — treat <tag> as a short commit SHA. |
| 148 | prefix_matches = await find_commits_by_prefix(session, tag) |
| 149 | if len(prefix_matches) == 1: |
| 150 | return prefix_matches[0].commit_id |
| 151 | if len(prefix_matches) > 1: |
| 152 | typer.echo( |
| 153 | f"❌ Ambiguous commit prefix '{tag[:8]}' " |
| 154 | f"— matches {len(prefix_matches)} commits:" |
| 155 | ) |
| 156 | for c in prefix_matches: |
| 157 | typer.echo(f" {c.commit_id[:8]} {c.message[:60]}") |
| 158 | typer.echo("Use a longer prefix or an exact tag string to disambiguate.") |
| 159 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 160 | |
| 161 | typer.echo( |
| 162 | f"❌ No commit found for tag or prefix '{tag}'. " |
| 163 | "Create the tag first: muse tag add <tag> [<commit>]" |
| 164 | ) |
| 165 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 166 | |
| 167 | |
| 168 | async def _release_async( |
| 169 | *, |
| 170 | tag: str, |
| 171 | audio_format: ReleaseAudioFormat, |
| 172 | output_dir: Optional[pathlib.Path], |
| 173 | render_audio: bool, |
| 174 | render_midi: bool, |
| 175 | export_stems: bool, |
| 176 | root: pathlib.Path, |
| 177 | session: AsyncSession, |
| 178 | ) -> ReleaseResult: |
| 179 | """Core release logic — injectable for tests. |
| 180 | |
| 181 | Resolves the tag to a commit ID, loads the snapshot manifest, and |
| 182 | delegates to ``build_release`` in the service layer. |
| 183 | |
| 184 | Args: |
| 185 | tag: Tag string or short commit SHA prefix. |
| 186 | audio_format: Target audio format for rendered files. |
| 187 | output_dir: Explicit output directory or None for the default path. |
| 188 | render_audio: Whether to render the primary MIDI to an audio file. |
| 189 | render_midi: Whether to bundle all MIDI files into a zip archive. |
| 190 | export_stems: Whether to export each MIDI track as a separate audio file. |
| 191 | root: Muse repository root. |
| 192 | session: Open async DB session. |
| 193 | |
| 194 | Returns: |
| 195 | ReleaseResult describing what was written. |
| 196 | |
| 197 | Raises: |
| 198 | typer.Exit: On user errors (missing tag, empty snapshot, etc.). |
| 199 | StorpheusReleaseUnavailableError: When audio render is requested and |
| 200 | Storpheus is unreachable. |
| 201 | """ |
| 202 | full_commit_id = await _resolve_tag_to_commit(session, root, tag) |
| 203 | |
| 204 | manifest = await get_commit_snapshot_manifest(session, full_commit_id) |
| 205 | if manifest is None: |
| 206 | typer.echo(f"❌ Commit {full_commit_id[:8]} not found in database.") |
| 207 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 208 | |
| 209 | if not manifest: |
| 210 | typer.echo( |
| 211 | f"⚠️ Snapshot for commit {full_commit_id[:8]} is empty — nothing to release." |
| 212 | ) |
| 213 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 214 | |
| 215 | out_dir = output_dir if output_dir is not None else _default_output_dir(tag) |
| 216 | storpheus_url = settings.storpheus_base_url |
| 217 | |
| 218 | return build_release( |
| 219 | tag=tag, |
| 220 | commit_id=full_commit_id, |
| 221 | manifest=manifest, |
| 222 | root=root, |
| 223 | output_dir=out_dir, |
| 224 | audio_format=audio_format, |
| 225 | render_audio=render_audio, |
| 226 | render_midi=render_midi, |
| 227 | export_stems=export_stems, |
| 228 | storpheus_url=storpheus_url, |
| 229 | ) |
| 230 | |
| 231 | |
| 232 | @app.callback(invoke_without_command=True) |
| 233 | def release( |
| 234 | ctx: typer.Context, |
| 235 | tag: str = typer.Argument( |
| 236 | ..., |
| 237 | help=( |
| 238 | "Tag or commit prefix to release (e.g. v1.0). " |
| 239 | "Tags are created via 'muse tag add <tag>'." |
| 240 | ), |
| 241 | ), |
| 242 | render_audio: bool = typer.Option( |
| 243 | False, |
| 244 | "--render-audio", |
| 245 | help="Render all MIDI snapshots to a single audio file via Storpheus.", |
| 246 | ), |
| 247 | render_midi: bool = typer.Option( |
| 248 | False, |
| 249 | "--render-midi", |
| 250 | help="Bundle all .mid files from the snapshot into a zip archive.", |
| 251 | ), |
| 252 | export_stems: bool = typer.Option( |
| 253 | False, |
| 254 | "--export-stems", |
| 255 | help="Export each instrument track as a separate audio file.", |
| 256 | ), |
| 257 | audio_format: ReleaseAudioFormat = typer.Option( |
| 258 | ReleaseAudioFormat.WAV, |
| 259 | "--format", |
| 260 | "-f", |
| 261 | help="Audio output format: wav | mp3 | flac (default: wav).", |
| 262 | case_sensitive=False, |
| 263 | ), |
| 264 | output_dir: Optional[pathlib.Path] = typer.Option( |
| 265 | None, |
| 266 | "--output-dir", |
| 267 | "-o", |
| 268 | help="Where to write release artifacts (default: ./releases/<tag>/).", |
| 269 | ), |
| 270 | as_json: bool = typer.Option( |
| 271 | False, |
| 272 | "--json", |
| 273 | help="Emit structured JSON output for agent consumption.", |
| 274 | ), |
| 275 | ) -> None: |
| 276 | """Export a tagged commit as distribution-ready release artifacts. |
| 277 | |
| 278 | Resolves TAG to a commit (via ``muse tag add``), fetches its snapshot, |
| 279 | and produces the requested artifacts under the output directory. Always |
| 280 | writes a ``release-manifest.json`` with SHA-256 checksums. |
| 281 | |
| 282 | Examples:: |
| 283 | |
| 284 | muse release v1.0 --render-audio |
| 285 | muse release v1.0 --render-midi --export-stems --format flac |
| 286 | muse release v1.0 --render-audio --output-dir ~/dist/v1.0 |
| 287 | """ |
| 288 | root = require_repo() |
| 289 | |
| 290 | async def _run() -> ReleaseResult: |
| 291 | async with open_session() as session: |
| 292 | return await _release_async( |
| 293 | tag=tag, |
| 294 | audio_format=audio_format, |
| 295 | output_dir=output_dir, |
| 296 | render_audio=render_audio, |
| 297 | render_midi=render_midi, |
| 298 | export_stems=export_stems, |
| 299 | root=root, |
| 300 | session=session, |
| 301 | ) |
| 302 | |
| 303 | try: |
| 304 | result = asyncio.run(_run()) |
| 305 | except typer.Exit: |
| 306 | raise |
| 307 | except StorpheusReleaseUnavailableError as exc: |
| 308 | typer.echo(f"❌ Storpheus not reachable — audio render aborted.\n{exc}") |
| 309 | logger.error("muse release: Storpheus unavailable: %s", exc) |
| 310 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 311 | except ValueError as exc: |
| 312 | typer.echo(f"❌ {exc}") |
| 313 | logger.error("muse release: %s", exc) |
| 314 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 315 | except Exception as exc: |
| 316 | typer.echo(f"❌ muse release failed: {exc}") |
| 317 | logger.error("muse release error: %s", exc, exc_info=True) |
| 318 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 319 | |
| 320 | if as_json: |
| 321 | payload = { |
| 322 | "tag": result.tag, |
| 323 | "commit_id": result.commit_id, |
| 324 | "commit_short": result.commit_id[:8], |
| 325 | "output_dir": str(result.output_dir), |
| 326 | "manifest_path": str(result.manifest_path), |
| 327 | "audio_format": result.audio_format.value, |
| 328 | "stubbed": result.stubbed, |
| 329 | "artifacts": [ |
| 330 | { |
| 331 | "path": str(a.path), |
| 332 | "sha256": a.sha256, |
| 333 | "size_bytes": a.size_bytes, |
| 334 | "role": a.role, |
| 335 | } |
| 336 | for a in result.artifacts |
| 337 | ], |
| 338 | } |
| 339 | typer.echo(json_mod.dumps(payload, indent=2)) |
| 340 | else: |
| 341 | non_manifest = [a for a in result.artifacts if a.role != "manifest"] |
| 342 | if non_manifest: |
| 343 | typer.echo( |
| 344 | f"✅ Release artifacts for tag {result.tag!r} " |
| 345 | f"(commit {result.commit_id[:8]}):" |
| 346 | ) |
| 347 | for a in non_manifest: |
| 348 | typer.echo(f" [{a.role}] {a.path}") |
| 349 | else: |
| 350 | typer.echo( |
| 351 | f"⚠️ No render flags specified — only manifest written for tag {result.tag!r}." |
| 352 | "\nUse --render-audio, --render-midi, or --export-stems." |
| 353 | ) |
| 354 | |
| 355 | typer.echo(f" [manifest] {result.manifest_path}") |
| 356 | |
| 357 | if result.stubbed: |
| 358 | typer.echo( |
| 359 | "⚠️ Audio files are MIDI stubs (Storpheus /render endpoint not yet deployed)." |
| 360 | ) |
| 361 | |
| 362 | logger.info( |
| 363 | "muse release: tag=%r commit=%s output_dir=%s artifacts=%d stubbed=%s", |
| 364 | result.tag, |
| 365 | result.commit_id[:8], |
| 366 | result.output_dir, |
| 367 | len(result.artifacts), |
| 368 | result.stubbed, |
| 369 | ) |