Files
UmbraAudio/scripts/publish_release.py
2026-10-18 19:01:04 -07:00

304 lines
12 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Publish a release: author a comment, approve it, then pin what shipped (spec 22).
A release is a pinned set of artifacts plus an authored comment. The comment is a step, not
a caption: it is authored, read, changed if wrong, and approved before anything can ship.
Its designator is date + time + a per-date sequence (``2026-10-03 #2 14:32:05``) so two
releases on one day are never ambiguous about which came last.
Releasing also grants **implicit approval** of every voice a listener hears in the audiobook.
Those voices are tagged, with the provenance kept in the immutable publication record.
# 1. see what would ship (read-only)
python scripts/publish_release.py --project "DealHunterShip_4.0e_whole-to-audio"
# 2. author a comment from the measured build (a DRAFT; read it)
python scripts/publish_release.py --project "..." --draft-comment --by worstauthor
# 3. change it, as many times as needed
python scripts/publish_release.py --project "..." --edit-comment <slug> --text "..."
# 4. approve it
python scripts/publish_release.py --project "..." --approve-comment <slug>
# 5. publish - pins hashes, tags implicitly approved voices
python scripts/publish_release.py --project "..." --publish --comment <slug> --by you
# later: a fix is a NEW edition, never an edit
python scripts/publish_release.py --project "..." --draft-comment --edition 2 \
--supersedes pub-001-2026-09-09
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import load_config
from umbraaudio.release import (
build_publication,
grant_implicit_voice_approval,
list_publications,
publication_path,
verify_publication,
write_publication,
)
from umbraaudio.release_comments import (
CommentError,
create_comment,
draft_comment_text,
list_comments,
load_comment,
require_approved,
update_comment,
)
def _git_commit() -> str | None:
import subprocess
try:
out = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, timeout=10, check=False,
)
return out.stdout.strip() or None
except (OSError, subprocess.SubprocessError):
return None
def _print_comment(comment) -> None: # noqa: ANN001 - ReleaseComment, avoids an import cycle
print("RELEASE COMMENT")
print(comment.render())
def _print_summary(record: dict) -> None:
profile = record["delivery_profile"]
approvals = record["approvals"]
comment = record.get("release_comment")
print(f"publication_id {record['publication_id']}")
print(f"work / edition {record['work']} #{record['edition_number']}"
+ (f" (supersedes {record['supersedes']})" if record.get("supersedes") else ""))
print(f"release comment {comment['designator']} [{comment['status']}]"
if comment else "release comment NONE - a release needs an approved comment")
print(f"chapters pinned {len(record['chapters'])}")
pkg = record["package"]
print(f"package {pkg['object_key']} {pkg['bytes']} bytes "
f"sha256={(pkg['sha256'] or '')[:16]}…")
print(f"delivery profile {profile['sample_rate']} Hz / {profile['channels']} ch"
+ (f" / {profile['bit_rate_kbps']} kbps" if profile.get("bit_rate_kbps") else "")
+ f" +{profile['lead_silence_s']}s lead, +{profile['trail_silence_s']}s trail"
f" rms {profile['target_rms_db']} dB")
print(f"approvals {approvals['voices_explicitly_approved']} voices explicitly "
f"approved; {approvals['voices_implicitly_approved']} approved implicitly by this "
f"release; {approvals['voices_in_profile_without_lines']} in the profile do not "
"speak and are NOT covered")
print(" chapter approved: yes | audiobook approved: yes "
"-> granted by this record")
warned = [c for c in record["chapters"] if c["warnings"]]
print(f"\npost-approval deltas on {len(warned)}/{len(record['chapters'])} chapters")
for chapter in warned[:3]:
for warning in chapter["warnings"]:
print(f" ch{chapter['chapter_id']:>3} {warning}")
if len(warned) > 3:
print(f" ... and {len(warned) - 3} more")
if record["blockers"]:
print(f"\nBLOCKERS ({len(record['blockers'])}) - not publishable:")
for blocker in record["blockers"]:
print(f" [{blocker['id']}] {blocker['why']}")
else:
print("\nno blockers: this set is publishable")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", default=None)
parser.add_argument("--publish", action="store_true")
parser.add_argument("--verify", metavar="PUB_ID", default=None)
parser.add_argument("--list", action="store_true")
parser.add_argument("--comments", action="store_true", help="list release comments")
parser.add_argument("--show-comment", metavar="SLUG", default=None)
parser.add_argument("--draft-comment", action="store_true",
help="author a comment from the measured build (a DRAFT)")
parser.add_argument("--edit-comment", metavar="SLUG", default=None)
parser.add_argument("--approve-comment", metavar="SLUG", default=None)
parser.add_argument("--comment", metavar="SLUG", default=None,
help="the approved comment to publish with")
parser.add_argument("--text", default=None, help="comment text (first line = summary)")
parser.add_argument("--edition", type=int, default=1)
parser.add_argument("--supersedes", default=None)
parser.add_argument("--by", default="operator")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
cfg = load_config(args.project)
data_dir = cfg.data_dir
work = cfg.project_dir.name
if args.list:
records = list_publications(data_dir)
if args.json:
print(json.dumps(records, indent=2))
elif not records:
print(f"no publications recorded for {work}")
else:
print(f"publications for {work}")
for r in records:
print(f" {r['publication_id']} edition {r['edition_number']} "
f"{r['published_at']} {r['chapters']} chapters"
+ (f" supersedes {r['supersedes']}" if r["supersedes"] else ""))
return 0
if args.comments:
rows = list_comments(data_dir)
if args.json:
print(json.dumps(rows, indent=2))
elif not rows:
print(f"no release comments for {work}")
else:
print(f"release comments for {work}")
for r in rows:
print(f" {r['designator']} [{r['status']:>8}] {r['slug']}")
print(f" {r['summary']}")
return 0
if args.show_comment:
comment = load_comment(data_dir, args.show_comment)
if comment is None:
print(f"no such comment: {args.show_comment}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(comment.as_dict(), indent=2))
else:
_print_comment(comment)
return 0
if args.approve_comment:
try:
comment = update_comment(
data_dir, args.approve_comment, text=args.text, status="approved"
)
except CommentError as exc:
print(str(exc), file=sys.stderr)
return 2
print("approved:")
_print_comment(comment)
print(f"\npublish with:\n scripts/publish_release.py --project {cfg.project_dir.name} "
f"--publish --comment {comment.slug} --by {args.by}")
return 0
if args.edit_comment:
if not args.text:
print("--edit-comment needs --text", file=sys.stderr)
return 1
try:
comment = update_comment(data_dir, args.edit_comment, text=args.text)
except CommentError as exc:
print(str(exc), file=sys.stderr)
return 2
print("updated:")
_print_comment(comment)
return 0
if args.draft_comment:
record = build_publication(
data_dir, work, edition_number=args.edition, supersedes=args.supersedes
)
text = draft_comment_text(record, args.by)
try:
comment = create_comment(
data_dir, text, author=args.by, facts={
"chapters": len(record["chapters"]),
"package_sha256": (record["package"] or {}).get("sha256"),
"built_at": (record["package"] or {}).get("built_at"),
"voices_implicitly_approved":
record["approvals"]["voices_implicitly_approved"],
},
)
except CommentError as exc:
print(str(exc), file=sys.stderr)
return 2
print("DRAFTED from the measured build. Read it, change it if it is wrong, then approve.")
_print_comment(comment)
return 0
if args.verify:
path = publication_path(data_dir, args.verify)
if not path.exists():
print(f"no such publication: {path}", file=sys.stderr)
return 1
result = verify_publication(data_dir, json.loads(path.read_text()))
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"verify {result['publication_id']}")
print(f" ok: {result['ok']}")
for d in result["drift"]:
print(f" DRIFT [{d['id']}] {d['why']}")
for w in result["warnings"][:10]:
print(f" note: {w}")
if len(result["warnings"]) > 10:
print(f" ... and {len(result['warnings']) - 10} more notes")
return 0 if result["ok"] else 2
comment = None
if args.comment:
try:
comment = require_approved(data_dir, args.comment)
except CommentError as exc:
print(f"refusing to publish: {exc}", file=sys.stderr)
return 2
record = build_publication(
data_dir,
work,
edition_number=args.edition,
published_by=args.by,
git_commit=_git_commit(),
supersedes=args.supersedes,
comment=comment.as_dict() if comment else None,
)
if not args.publish:
if args.json:
print(json.dumps(record, indent=2, sort_keys=True))
else:
_print_summary(record)
print("\nDRY RUN - nothing written.")
if not comment:
print("next: --draft-comment, read it, then --approve-comment <slug>")
return 0 if record["publishable"] else 2
if not record["publishable"]:
print("refusing to publish: blockers present", file=sys.stderr)
_print_summary(record)
return 2
path = write_publication(data_dir, record)
granted = grant_implicit_voice_approval(data_dir, record)
print(f"published -> {path}")
if comment:
print(f" comment {comment.designator} ({comment.slug})")
print(f" pinned {len(record['chapters'])} chapters + package by sha256")
if granted["updated"]:
print(f" implicit approval granted to {len(granted['updated'])} voice(s) "
f"heard in this release: {', '.join(granted['updated'][:8])}"
+ (" ..." if len(granted["updated"]) > 8 else ""))
print(f" basis: {granted['basis']}; provenance kept in this record")
print(f" next release edition {args.edition + 1} with "
f"--supersedes {record['publication_id']} - this record is immutable.")
return 0
if __name__ == "__main__":
raise SystemExit(main())