304 lines
12 KiB
Python
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())
|