From 82eea674a54c1a3d81c8da7a04f3123044685ba9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 1 Oct 2026 18:47:10 -0400 Subject: [PATCH] feat(release): replace audio helper with Go podcast CLI Signed-off-by: Deluan --- ...io-tests.yml => release-podcast-tests.yml} | 16 +- ...{release-audio.yml => release-podcast.yml} | 40 +- release/audio/.gitignore | 1 - release/audio/README.md | 180 ---- release/audio/release_audio.py | 772 ------------------ release/audio/test_release_audio.py | 655 --------------- release/podcast/README.md | 232 ++++++ release/podcast/actions.go | 176 ++++ release/podcast/main.go | 182 +++++ release/podcast/pipeline.go | 471 +++++++++++ release/podcast/podcast_test.go | 676 +++++++++++++++ release/{audio => podcast}/prompt.txt | 0 release/podcast/source.go | 292 +++++++ .../{audio => podcast}/testdata/releases.json | 0 release/podcast/validation.go | 219 +++++ 15 files changed, 2277 insertions(+), 1635 deletions(-) rename .github/workflows/{release-audio-tests.yml => release-podcast-tests.yml} (57%) rename .github/workflows/{release-audio.yml => release-podcast.yml} (77%) delete mode 100644 release/audio/.gitignore delete mode 100644 release/audio/README.md delete mode 100644 release/audio/release_audio.py delete mode 100644 release/audio/test_release_audio.py create mode 100644 release/podcast/README.md create mode 100644 release/podcast/actions.go create mode 100644 release/podcast/main.go create mode 100644 release/podcast/pipeline.go create mode 100644 release/podcast/podcast_test.go rename release/{audio => podcast}/prompt.txt (100%) create mode 100644 release/podcast/source.go rename release/{audio => podcast}/testdata/releases.json (100%) create mode 100644 release/podcast/validation.go diff --git a/.github/workflows/release-audio-tests.yml b/.github/workflows/release-podcast-tests.yml similarity index 57% rename from .github/workflows/release-audio-tests.yml rename to .github/workflows/release-podcast-tests.yml index 49307c603..6d93ebb63 100644 --- a/.github/workflows/release-audio-tests.yml +++ b/.github/workflows/release-podcast-tests.yml @@ -1,14 +1,14 @@ -name: Release audio offline tests +name: Release podcast offline tests on: pull_request: paths: - - release/audio/** - - .github/workflows/release-audio*.yml + - release/podcast/** + - .github/workflows/release-podcast*.yml push: branches: [master] paths: - - release/audio/** - - .github/workflows/release-audio*.yml + - release/podcast/** + - .github/workflows/release-podcast*.yml permissions: contents: read jobs: @@ -19,11 +19,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - python-version: '3.12' + go-version-file: go.mod - name: Install tools for the offline MP3 decode test run: | sudo apt-get update sudo apt-get install -y --no-install-recommends ffmpeg - - run: python3 -m unittest discover -s release/audio -p 'test_*.py' -v + - run: go test -race -count=1 -v ./release/podcast diff --git a/.github/workflows/release-audio.yml b/.github/workflows/release-podcast.yml similarity index 77% rename from .github/workflows/release-audio.yml rename to .github/workflows/release-podcast.yml index cc078c734..30be940d4 100644 --- a/.github/workflows/release-audio.yml +++ b/.github/workflows/release-podcast.yml @@ -1,4 +1,4 @@ -name: Release audio +name: Release podcast on: release: @@ -29,7 +29,7 @@ permissions: {} # Serialize the artifact lookup and reservation, including overlapping rollups. # GitHub may replace an older pending run; recover that run with manual dispatch. concurrency: - group: release-audio-${{ github.repository }} + group: release-podcast-${{ github.repository }} cancel-in-progress: false jobs: @@ -55,9 +55,11 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - python-version: '3.12' + go-version-file: go.mod + - name: Build the shared Go CLI before exposing credentials + run: go build -o "$RUNNER_TEMP/release-podcast" ./release/podcast - name: Install MP3 inspection tools before generation if: env.AUDIO_MODE == 'audio' && env.AUDIO_ENABLED == 'true' run: | @@ -68,13 +70,13 @@ jobs: env: GH_TOKEN: ${{ github.token }} AUDIO_KEY_CONFIGURED: ${{ secrets.OPENAI_API_KEY != '' }} - run: python3 release/audio/release_audio.py prepare + run: '"$RUNNER_TEMP/release-podcast" --github-stage prepare' - name: Reserve this paid attempt before contacting OpenAI if: steps.prepare.outputs.generate == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.prepare.outputs.reservation }} - path: release-audio/manifest.json + path: release-podcast/manifest.json if-no-files-found: error retention-days: 90 - name: Generate and validate the grounded script @@ -82,17 +84,17 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GH_TOKEN: ${{ github.token }} - run: python3 release/audio/release_audio.py script + run: '"$RUNNER_TEMP/release-podcast" --github-stage script' - name: Checkpoint the validated script if: steps.prepare.outputs.generate == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-audio-script-${{ github.run_id }}-${{ github.run_attempt }} + name: release-podcast-script-${{ github.run_id }}-${{ github.run_attempt }} path: | - release-audio/transcript.txt - release-audio/sources.json - release-audio/evidence.json - release-audio/manifest.json + release-podcast/transcript.txt + release-podcast/sources.json + release-podcast/evidence.json + release-podcast/manifest.json if-no-files-found: error retention-days: 30 - name: Synthesize and inspect the MP3 @@ -100,18 +102,18 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GH_TOKEN: ${{ github.token }} - run: python3 release/audio/release_audio.py speech + run: '"$RUNNER_TEMP/release-podcast" --github-stage speech' - name: Upload review artifacts if: always() && steps.prepare.outputs.prepared == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-audio-${{ github.run_id }}-${{ github.run_attempt }} + name: release-podcast-${{ github.run_id }}-${{ github.run_attempt }} path: | - release-audio/transcript.txt - release-audio/release-audio.mp3 - release-audio/sources.json - release-audio/evidence.json - release-audio/manifest.json + release-podcast/transcript.txt + release-podcast/release-podcast.mp3 + release-podcast/sources.json + release-podcast/evidence.json + release-podcast/manifest.json if-no-files-found: error retention-days: 30 compression-level: 0 diff --git a/release/audio/.gitignore b/release/audio/.gitignore deleted file mode 100644 index c18dd8d83..000000000 --- a/release/audio/.gitignore +++ /dev/null @@ -1 +0,0 @@ -__pycache__/ diff --git a/release/audio/README.md b/release/audio/README.md deleted file mode 100644 index ea15c507e..000000000 --- a/release/audio/README.md +++ /dev/null @@ -1,180 +0,0 @@ -# Release audio - -`Release audio` turns **published GitHub release notes** into a grounded English -recap, then an MP3, with a transcript and source/evidence records in Actions -artifacts. The target is about two minutes: 250–280 words and 105–145 seconds. -Small releases may be shorter; correctness takes priority over filler. Nothing -is uploaded to release assets or social media. Audio failure cannot block the -existing software release pipeline. - -## Setup (maintainer) - -Generation is **disabled by default**. No model or voice is selected by default. -Choose models and audition a stock voice before enabling production generation. - -1. Review the workflow/helper and model pricing profiles below. -2. Set repository variables `RELEASE_AUDIO_TEXT_MODEL`, - `RELEASE_AUDIO_TTS_MODEL`, and `RELEASE_AUDIO_VOICE` to reviewed options. -3. Add `OPENAI_API_KEY` yourself in GitHub Actions Secrets. Use a dedicated - OpenAI project key with the necessary model/endpoint access and usage alerts. -4. After deciding to permit paid generation, set the repository variable - `RELEASE_AUDIO_ENABLED` to the literal `true`. -5. Once merged, run **Actions → Release audio → Run workflow** on `master`. - Start with `validate` and `v0.64.0,v0.64.1,v0.64.2`, then explicitly choose - `script` or `audio` when ready to incur API usage. -6. Compare the transcript and evidence with the sources, then listen to the - entire MP3 before public use. Every transcript discloses the AI voice. - -Supported options (not finalized user choices): - -| Setting | Reviewed options | -| --- | --- | -| Text model | `gpt-6-luna` (low reasoning), `gpt-4.1-mini-2025-04-14` | -| Speech model | `gpt-4o-mini-tts-2025-12-15`, `tts-1`, `tts-1-hd` | -| Legacy voices | `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer` | -| Mini TTS voices | Legacy voices plus `ash`, `ballad`, `coral`, `sage`, `verse`, `marin`, `cedar` | - -Onyx and cedar are candidates to audition for the requested male-style delivery; -perceived voice gender is subjective. The API selects a stock voice by name. -Mini TTS receives fixed calm English speaking instructions. Legacy TTS models do -not accept those instructions. Unsupported models fail before paid requests; -adding a model requires a code/pricing review. Luna is a verified API alias; -unlike the pinned 4.1/Mini TTS snapshots, its behavior can change over time. - -## Publication and manual modes - -The automatic trigger is `release: published`, with draft and prerelease guards. -Automatic generation is skipped while the enablement variable is unset/false. -It resolves the **event's release ID**, never `/releases/latest`. Tag pushes, -note edits, and unpublished drafts do not generate audio. Promotion of an -already-published prerelease may require manual dispatch. Manual input accepts -at most three distinct version tags, sorted by numeric version; it produces -one combined recap. Prereleases need the explicit manual opt-in. - -The current `pipeline.yml` calls GoReleaser with `GITHUB_TOKEN`, and -`release/goreleaser.yml` has `draft: true`. This implementation leaves that -publishing behavior intact. Publish the prepared draft through GitHub as a -maintainer to trigger audio. **Publication by `GITHUB_TOKEN` generally suppresses -downstream release events.** If that becomes the publication method, dispatch -this workflow manually after successful publication, or separately review an -explicit `workflow_dispatch` integration with narrow Actions permissions. -Do not add a broad PAT or change draft publication to make this work. - -Land the workflow before the next release tag. GitHub associates release events -with the tagged commit, so historical tags cannot be assumed to contain a newly -added workflow. Dispatch from the default branch for the 0.64 prototype; do not -move tags. Manual dispatch requires the workflow on the default branch. The -helper always executes from the reviewed default branch, with credentials not -persisted in checkout. Branch protection should protect this implementation. - -| Mode | OpenAI requests | Output | -| --- | --- | --- | -| `validate` | None | Exact sources, manifest, modeled estimate when models are configured | -| `script` | At most one Responses request | Transcript, evidence/caution map, sources, manifest | -| `audio` | At most one Responses request and one speech request | Script outputs plus validated MP3 | - -## Limits, evidence, and costs - -There are no HTTP/SDK/repair retries. Timeouts can already be billed. Requests -have a 60-second timeout; the job has a 10-minute timeout. Sources and the full -prompt have separate 64 KiB byte caps. Oversized material fails without -truncation. Text output is capped at 3,000 tokens (including reasoning and the -structured evidence map); narration at 280 words and 2,500 characters. -Mini TTS adds a conservative 2,000 UTF-8-byte input ceiling, including speaking -instructions, to stay below its 2,000-input-token limit without a tokenizer -dependency. A dense script may need shortening; it is never truncated. - -The versioned prompt treats notes as untrusted evidence and requests strict JSON -with sentence-to-source excerpts and required caution coverage. Source IDs and -literal excerpts must match. Migration paragraphs, security messages, and -experimental/opt-in warnings must be mapped; lexical guards preserve the -prototype's backup, client-resync, plugin networking, Docker discovery, and -32-bit/slow-storage scan cautions. Neither literal matches nor keyword checks -prove a paraphrase is true. **Human factual and listening review remains -required.** English/markup checks are heuristics, not a language classifier. -No source links or draft advisories are fetched. Only public release-note text -and the validated script go to fixed OpenAI endpoints. `store: false` does not -imply zero provider retention. - -Rates checked 2026-10-01, USD per million units: - -| Model | Input | Output | -| --- | ---: | ---: | -| GPT-6 Luna | $0.10/text token | $0.50/text token | -| GPT-4.1 mini | $0.40/text token | $1.60/text token | -| TTS-1 | $15/character | — | -| TTS-1 HD | $30/character | — | -| GPT-4o mini TTS | $0.60/text token | $12/audio token | - -Preflight rejects a modeled request allowance above **$0.10**, counting the -whole serialized prompt as UTF-8 bytes plus 1,024 framing tokens and maximum -text output. Legacy speech uses the character ceiling. Mini TTS uses a -conservative **6,000 audio-token allowance** plus input. Its API does not expose -an enforceable output-token or dollar cap, so this is a modeled allowance, -not a billing guarantee. The transcript/request caps bound the work; changed -prices, anomalous speech length, taxes, GitHub usage, and subsequent deliberate -runs remain outside the estimate. Do not reuse legacy character pricing for -Mini TTS. Project budget alerts are soft thresholds, not hard spend caps. - -The original approximately $0.03 single-pass estimate described illustrative -4.1 mini + TTS-1 inputs. It is not a promise for every supported combination, -and neither that estimate nor this PR authorizes a paid prototype run. - -## Duplicate attempts, checkpoints, and retention - -All release-audio jobs serialize separately from the build pipeline. Before -any paid request, the helper checks up to 10,000 repository artifacts, failing -closed if the lookup fails or exceeds that bound. A reservation artifact is -uploaded **before** generation, keyed by repository, sorted release IDs, and -mode. Attempts with the same key are skipped even after failure or source/model -changes. `force_regenerate=true` is a deliberate manual opt-in to another paid -attempt. Script and audio modes have separate keys; a rollup and a single -release are different source sets. Force attempts are also recorded. - -Reservation artifacts last 90 days, limited by repository retention policy; -review outputs and script checkpoints last 30 days. This is best-effort -deduplication within retained artifacts, not a permanent exactly-once ledger. -Deleting/expiring reservations permits another attempt. GitHub may replace an -older pending concurrency run; dispatch that run manually if needed. - -The validated script is checkpointed before speech. Sources are fetched again -before each paid stage; unpublished/deleted/edited notes stop the attempt. -Invalid scripts never reach TTS; error JSON never becomes an MP3. `ffprobe` -checks codec and duration, and `ffmpeg` decodes the entire MP3 before acceptance. -Duration deviations are flagged without regenerating. Manifests include -implementation/event commits, source/prompt/script/audio hashes, models, voice, -and a complete generation fingerprint, -request counts, text usage, and duration. Failed attempts retain safe diagnostics -and any completed checkpoints through the final artifact step. - -A rerun skips a reserved attempt; do not force regeneration to fix an upload. -If the runner/files are lost, this implementation deliberately has no automatic -cross-run script recovery: download the checkpoint for review, and decide -whether a new manual forced attempt is warranted. That attempt may be billed. -Artifacts need a signed-in GitHub account with repository read access and expire; -they are not permanent anonymous podcast URLs. - -## Offline verification - -```sh -python3 -m unittest discover -s release/audio -p 'test_*.py' -v -``` - -The suite blocks unmocked network access. Fixtures preserve the public bodies -of v0.64.0/.1/.2 and test consequential omissions, unsafe tags/output, disabled -generation, exact event identity, changing notes, duplicates/expiry/force, -budget caps, error responses, and no retries. The separate PR test workflow -never receives `OPENAI_API_KEY`. Generation uses only Python's standard library. -Both workflows install `ffprobe` and `ffmpeg` in a credential-free step; audio -generation installs them before source preparation or any OpenAI request. - -## References - -- [GitHub release events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#release) -- [GITHUB_TOKEN event suppression](https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow) -- [GPT-6 Luna](https://developers.openai.com/api/docs/models/gpt-6-luna) -- [GPT-4.1 mini](https://developers.openai.com/api/docs/models/gpt-4.1-mini) -- [Mini TTS](https://developers.openai.com/api/docs/models/gpt-4o-mini-tts) -- [TTS-1](https://developers.openai.com/api/docs/models/tts-1) -- [TTS-1 HD](https://developers.openai.com/api/docs/models/tts-1-hd) -- [Speech API guide and AI-voice disclosure](https://developers.openai.com/api/docs/guides/text-to-speech) diff --git a/release/audio/release_audio.py b/release/audio/release_audio.py deleted file mode 100644 index ec999892d..000000000 --- a/release/audio/release_audio.py +++ /dev/null @@ -1,772 +0,0 @@ -#!/usr/bin/env python3 -"""Bounded, artifact-only release narration; Python standard library only.""" - -import argparse -import hashlib -import json -import math -import os -import re -import subprocess -import sys -import urllib.error -import urllib.parse -import urllib.request -from datetime import datetime, timezone -from pathlib import Path - -REPOSITORY = "navidrome/navidrome" -OUT = Path("release-audio") -PROMPT = Path(__file__).with_name("prompt.txt").read_text(encoding="utf-8") -MAX_SOURCE_BYTES = 65536 -MAX_PROMPT_BYTES = 65536 -MAX_OUTPUT_TOKENS = 3000 -MAX_SCRIPT_CHARS = 2500 -MINI_TTS_INPUT_BYTES = 2000 -MAX_COST_USD = 0.10 -# Supported profiles are options, NOT a selected/default model. Unknown pricing -# must be reviewed here before a model can be used. Rates checked 2026-10-01. -TEXT_MODELS = {"gpt-4.1-mini-2025-04-14": (0.40, 1.60), "gpt-6-luna": (0.10, 0.50)} -TTS_MODELS = {"tts-1": 15.0, "tts-1-hd": 30.0, "gpt-4o-mini-tts-2025-12-15": None} -VOICES = {"alloy", "echo", "fable", "onyx", "nova", "shimmer"} -MODERN_VOICES = VOICES | {"ash", "ballad", "coral", "sage", "verse", "marin", "cedar"} -SPEECH_INSTRUCTIONS = ( - "Speak in clear, calm English with a warm, measured delivery. Read API as letters." -) -# A conservative modeled allowance, NOT an enforceable speech output-token cap. -MODELED_AUDIO_TOKENS = 6000 -TAG = re.compile( - r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*)?" -) -INTRO = ( - "Welcome to the Navidrome release recap. This narration uses an AI-generated voice." -) -CLOSING = "For the full details and upgrade guidance, read the official release notes linked alongside this transcript." -SCHEMA = { - "type": "object", - "additionalProperties": False, - "required": ["sentences", "cautions"], - "properties": { - "sentences": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "required": ["text", "source_id", "excerpt"], - "properties": { - key: {"type": "string"} for key in ("text", "source_id", "excerpt") - }, - }, - }, - "cautions": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "required": ["caution_id", "sentence_index"], - "properties": { - "caution_id": {"type": "string"}, - "sentence_index": {"type": "integer"}, - }, - }, - }, - }, -} - - -class AudioError(Exception): - """Safe diagnostics: never include remote bodies, headers or secrets.""" - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, *_args): - return None - - -def digest(value): - return hashlib.sha256(value).hexdigest() - - -def canonical(value): - return json.dumps(value, sort_keys=True, ensure_ascii=False).encode("utf-8") - - -def write_json(name, value): - OUT.mkdir(exist_ok=True) - (OUT / name).write_text( - json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" - ) - - -def read_json(name): - return json.loads((OUT / name).read_text(encoding="utf-8")) - - -def request(url, token, payload=None, limit=1048576): - # No SDK retries; even a timeout may already have incurred a charge. - req = urllib.request.Request( - url, data=canonical(payload) if payload is not None else None - ) - req.add_header("Authorization", "Bearer " + token) - req.add_header("Content-Type", "application/json") - req.add_header("Accept", "application/json" if payload is None else "*/*") - try: - with urllib.request.build_opener(NoRedirect()).open( - req, timeout=60 - ) as response: - data = response.read(limit + 1) - content_type = response.headers.get_content_type() - except urllib.error.HTTPError as exc: - exc.close() - raise AudioError(f"HTTP {exc.code}; requests are not retried") from None - except (urllib.error.URLError, TimeoutError, OSError): - raise AudioError("Network request failed; requests are not retried") from None - if len(data) > limit: - raise AudioError("Response exceeds the size limit") - return data, content_type - - -def github(path): - data, _ = request( - "https://api.github.com/repos/" + REPOSITORY + path, os.environ["GH_TOKEN"] - ) - return json.loads(data) - - -def parse_tags(raw): - tags = [tag.strip() for tag in raw.split(",")] - if not 1 <= len(tags) <= 3 or len(set(tags)) != len(tags): - raise AudioError("Supply one to three distinct release tags") - if any(len(tag) > 80 or not TAG.fullmatch(tag) for tag in tags): - raise AudioError("Tags must be versions such as v0.64.2") - return sorted( - tags, key=lambda tag: tuple(int(n) for n in TAG.fullmatch(tag).groups()) - ) - - -def normalize_release(record, allow_prerelease=False): - tag = record.get("tag_name", "") - if not isinstance(tag, str) or len(tag) > 80 or not TAG.fullmatch(tag): - raise AudioError("Release tag must be a single supported version") - parse_tags(tag) - if record.get("draft") is not False or not record.get("published_at"): - raise AudioError("Only published releases are eligible") - if record.get("prerelease") is not False and not allow_prerelease: - raise AudioError("Prereleases require manual opt-in") - body = record.get("body") - if not isinstance(body, str) or not body.strip(): - raise AudioError("Release notes are empty; add notes before generating") - if type(record.get("id")) is not int or record["id"] <= 0: - raise AudioError("Invalid release ID") - if len(body.encode("utf-8")) > MAX_SOURCE_BYTES: - raise AudioError( - "Release notes exceed the source limit; review them without truncation" - ) - return { - "id": record["id"], - "source_id": str(record["id"]), - "tag": tag, - "url": "https://github.com/" - + REPOSITORY - + "/releases/tag/" - + urllib.parse.quote(tag, safe=""), - "published_at": record["published_at"], - "updated_at": record.get("updated_at"), - "body": body.replace("\r\n", "\n"), - "prerelease": record["prerelease"], - } - - -def source_input(sources): - # Drop only HTML comments. Preserve all sections, including anything added - # after the promotional footer: a warning must never be silently truncated. - result = [] - for source in sources: - body = re.sub(r"", "", source["body"], flags=re.S) - result.append( - {"source_id": source["source_id"], "tag": source["tag"], "body": body} - ) - return result - - -def cautions(sources): - # Every migration paragraph is mandatory. Security is grouped per release; - # behavioral qualifiers outside those sections are also mandatory. - result = [] - for source in source_input(sources): - in_migration = False - security_added = False - for line in source["body"].splitlines(): - if line.startswith("## "): - in_migration = bool(re.search(r"breaking|migration", line, re.I)) - mandatory = in_migration and line.startswith("- ") - if re.search(r"security", line, re.I) and not security_added: - mandatory = True - security_added = True - mandatory |= bool( - re.search( - r"back.?up|re-?sync|experimental|opt-in|disabled|host networking", - line, - re.I, - ) - ) - if mandatory: - result.append( - { - "caution_id": f"c{len(result)}", - "source_id": source["source_id"], - "excerpt": line, - } - ) - return result - - -def config(mode): - text = os.environ.get("AUDIO_TEXT_MODEL", "") - tts = os.environ.get("AUDIO_TTS_MODEL", "") - voice = os.environ.get("AUDIO_VOICE", "") - if mode != "validate" and text not in TEXT_MODELS: - raise AudioError("Set RELEASE_AUDIO_TEXT_MODEL to a reviewed supported model") - voices = MODERN_VOICES if tts == "gpt-4o-mini-tts-2025-12-15" else VOICES - if mode == "audio" and (tts not in TTS_MODELS or voice not in voices): - raise AudioError( - "Set RELEASE_AUDIO_TTS_MODEL and RELEASE_AUDIO_VOICE to reviewed options" - ) - return { - "text_model": text, - "tts_model": tts, - "voice": voice, - "speed": 1.0, - "speech_instructions": SPEECH_INSTRUCTIONS - if tts == "gpt-4o-mini-tts-2025-12-15" - else "", - } - - -def narration_byte_limit(selected): - if selected["speech_instructions"]: - return MINI_TTS_INPUT_BYTES - len(selected["speech_instructions"].encode()) - return MAX_SCRIPT_CHARS - - -def text_payload(sources, selected): - payload = { - "model": selected["text_model"], - "store": False, - "max_output_tokens": MAX_OUTPUT_TOKENS, - "instructions": PROMPT, - "input": json.dumps( - { - "sources": source_input(sources), - "required_cautions": cautions(sources), - "introduction": INTRO, - "closing": CLOSING, - "narration_limits": { - "max_words": 280, - "max_characters": MAX_SCRIPT_CHARS, - "max_utf8_bytes": narration_byte_limit(selected), - }, - }, - ensure_ascii=False, - ), - "text": { - "format": { - "type": "json_schema", - "name": "release_narration", - "strict": True, - "schema": SCHEMA, - } - }, - } - if selected["text_model"] == "gpt-6-luna": - payload["reasoning"] = {"effort": "low"} - return payload - - -def cost_bound(payload, selected, mode): - size = len(canonical(payload)) - if size > MAX_PROMPT_BYTES: - raise AudioError( - "Complete prompt exceeds its byte limit; sources must be reviewed, never truncated" - ) - if selected["text_model"] not in TEXT_MODELS: - return None - # Byte-level tokenizers cannot emit more text tokens than UTF-8 bytes; - # add 1024 for protocol framing. Count the whole JSON envelope, schema too. - input_rate, output_rate = TEXT_MODELS[selected["text_model"]] - bound = ((size + 1024) * input_rate + MAX_OUTPUT_TOKENS * output_rate) / 1000000 - if mode == "audio": - if TTS_MODELS[selected["tts_model"]] is None: - # English speech input: at most 2500 ASCII characters + instruction - # bytes, conservatively treated as tokens. Enforce 2000 below too. - bound += ( - (MAX_SCRIPT_CHARS + len(SPEECH_INSTRUCTIONS.encode())) * 0.60 - + MODELED_AUDIO_TOKENS * 12 - ) / 1000000 - else: - bound += MAX_SCRIPT_CHARS * TTS_MODELS[selected["tts_model"]] / 1000000 - if bound > MAX_COST_USD: - raise AudioError( - "Modeled request cost exceeds $0.10; review models or source size" - ) - return round(bound, 6) - - -def duplicate(reservation): - # Fail closed if the ledger cannot be fully scanned within this bound. - for page in range(1, 101): - records = github(f"/actions/artifacts?per_page=100&page={page}")["artifacts"] - if any( - (item["name"] == reservation or item["name"].startswith(reservation + "-")) - and not item["expired"] - for item in records - ): - return True - if len(records) < 100: - return False - raise AudioError("Artifact ledger exceeds lookup limit; manual review required") - - -def emit_output(key, value): - path = os.environ.get("GITHUB_OUTPUT") - if path: - with open(path, "a", encoding="utf-8") as output: - output.write(f"{key}={value}\n") - - -def summary(message): - path = os.environ.get("GITHUB_STEP_SUMMARY") - if path: - with open(path, "a", encoding="utf-8") as output: - output.write(message + "\n") - - -def prepare(): - event = json.loads( - Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8") - ) - if os.environ.get("GITHUB_REPOSITORY") != REPOSITORY: - raise AudioError("This workflow is restricted to navidrome/navidrome") - manual = os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" - inputs = event.get("inputs", {}) if manual else {} - allow = inputs.get("include_prereleases") in (True, "true") - force = inputs.get("force_regenerate") in (True, "true") - mode = inputs.get("mode", "validate") if manual else "audio" - if mode not in {"validate", "script", "audio"}: - raise AudioError("Invalid mode") - if manual: - expected_ref = "refs/heads/" + event["repository"]["default_branch"] - if os.environ.get("GITHUB_REF") != expected_ref: - raise AudioError("Manual generation must use the default branch") - sources = [ - normalize_release( - github("/releases/tags/" + urllib.parse.quote(tag, safe="")), allow - ) - for tag in parse_tags(inputs.get("tags", "")) - ] - else: - if event.get("action") != "published": - raise AudioError("Expected a release published event") - normalize_release(event["release"]) - sources = [normalize_release(github(f"/releases/{event['release']['id']}"))] - if sources[0]["tag"] != event["release"]["tag_name"]: - raise AudioError("Release identity changed") - if len({source["id"] for source in sources}) != len(sources): - raise AudioError("Duplicate release IDs") - if ( - sum(len(source["body"].encode("utf-8")) for source in sources) - > MAX_SOURCE_BYTES - ): - raise AudioError("Combined sources exceed the limit") - selected = config(mode) - payload = text_payload(sources, selected) - bound = cost_bound(payload, selected, mode) - # Reserve by release identities AND mode; a script-only run may later get audio. - # Model/source changes do not silently bypass the paid-attempt ledger. - reservation = "release-audio-attempt-" + digest( - canonical( - {"repo": REPOSITORY, "ids": sorted(s["id"] for s in sources), "mode": mode} - ) - ) - enabled = os.environ.get("AUDIO_ENABLED") == "true" - if mode != "validate" and not enabled: - raise AudioError( - "Paid generation is disabled; set RELEASE_AUDIO_ENABLED after reviewing setup" - ) - if mode != "validate" and os.environ.get("AUDIO_KEY_CONFIGURED") != "true": - raise AudioError( - "Add OPENAI_API_KEY in Actions Secrets before reserving a paid attempt" - ) - repeated = mode != "validate" and duplicate(reservation) - manifest = { - "version": 1, - "mode": mode, - "config": selected, - "cost_bound_usd": bound, - "source_sha256": digest(canonical(sources)), - "prompt_sha256": digest(PROMPT.encode()), - "implementation_sha": subprocess.run( - ["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True - ).stdout.strip(), - "event_sha": os.environ.get("GITHUB_SHA", ""), - "run_id": os.environ.get("GITHUB_RUN_ID", ""), - "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", ""), - "created_at": datetime.now(timezone.utc).isoformat(), - "reservation": reservation, - "cost_is_modeled": True, - "modeled_audio_tokens": MODELED_AUDIO_TOKENS - if selected["speech_instructions"] - else None, - "requests": {"text": 0, "speech": 0}, - "force_regenerate": force, - "include_prereleases": allow, - "status": "duplicate" if repeated and not force else "prepared", - } - manifest["generation_sha256"] = digest( - canonical( - { - "sources": sources, - "config": selected, - "prompt": PROMPT, - "implementation": manifest["implementation_sha"], - } - ) - ) - write_json("sources.json", sources) - write_json("manifest.json", manifest) - emit_output( - "reservation", reservation + f"-{manifest['run_id']}-{manifest['run_attempt']}" - ) - emit_output("prepared", "true") - emit_output( - "generate", - str(mode != "validate" and manifest["status"] != "duplicate").lower(), - ) - summary( - f"Release audio: {manifest['status']}; mode: {mode}. Modeled API bound: {bound} USD.\n" - "Review artifacts contain the exact published sources. Listen and compare the transcript before distribution." - ) - - -def recheck_sources(manifest, sources): - latest = [ - normalize_release( - github(f"/releases/{source['id']}"), manifest["include_prereleases"] - ) - for source in sources - ] - if digest(canonical(latest)) != manifest["source_sha256"]: - raise AudioError( - "Published notes changed; stop and review a new manual attempt" - ) - - -def validate_script(result, sources): - if not isinstance(result, dict) or set(result) != {"sentences", "cautions"}: - raise AudioError("Invalid narration schema") - sentences = result["sentences"] - if not isinstance(sentences, list) or not 1 <= len(sentences) <= 35: - raise AudioError("Invalid narration sentence count") - by_id = {source["source_id"]: source for source in sources} - for sentence in sentences: - if not isinstance(sentence, dict) or set(sentence) != { - "text", - "source_id", - "excerpt", - }: - raise AudioError("Invalid sentence schema") - if not all( - isinstance(value, str) and value.strip() for value in sentence.values() - ): - raise AudioError("Empty or invalid sentence evidence") - source = by_id.get(sentence["source_id"]) - if ( - source is None - or len(sentence["excerpt"]) < 12 - or sentence["excerpt"] not in source["body"] - ): - raise AudioError("Sentence evidence is absent from its source") - required = {item["caution_id"]: item for item in cautions(sources)} - covered = set() - if not isinstance(result["cautions"], list): - raise AudioError("Invalid caution coverage") - for item in result["cautions"]: - if not isinstance(item, dict) or set(item) != {"caution_id", "sentence_index"}: - raise AudioError("Invalid caution schema") - index = item["sentence_index"] - caution = required.get(item["caution_id"]) - if caution is None or type(index) is not int or not 0 <= index < len(sentences): - raise AudioError("Invalid caution reference") - if sentences[index]["source_id"] != caution["source_id"]: - raise AudioError("Caution mapped to the wrong release") - covered.add(item["caution_id"]) - if covered != set(required): - raise AudioError("Missing required migration/security/qualifier coverage") - text = ( - INTRO - + "\n\n" - + " ".join(s["text"].strip() for s in sentences) - + "\n\n" - + CLOSING - ) - if not 100 <= len(text.split()) <= 280 or len(text) > MAX_SCRIPT_CHARS: - raise AudioError("Narration must be 100-280 words and at most 2500 characters") - if re.search(r"https?://|www\.|[@`<>{}\[\]#]|\$\(|\x00|[\x01-\x08\x0b-\x1f]", text): - raise AudioError("Narration contains markup, URL, handle or executable content") - if sum(ord(c) < 128 for c in text) / len(text) < 0.95: - raise AudioError("Narration must be English plain text") - if any(source["tag"].removeprefix("v") not in text for source in sources): - raise AudioError("Narration must identify each release version") - validate_qualifiers(sentences, sources) - return text + "\n" - - -def validate_qualifiers(sentences, sources): - # Lexical guards for consequential source conditions. These complement the - # evidence map; neither can prove semantic entailment. Human review remains. - rules = [ - ( - r"back up your database before upgrading", - [r"back.?up", r"database", r"before.{0,40}upgrad"], - ), - (r"may need to re-sync", [r"re.?sync"]), - ( - r"experimental Jellyfin", - [r"experimental", r"enabl|opt.in|default.off|disabled by default"], - ), - ( - r"Plugin authors", - [r"plugin", r"host.{0,20}HTTP|host.{0,20}network", r"private|loopback|LAN"], - ), - (r"security release.*?Upgrade", [r"security", r"upgrad"]), - (r"opt-in LAN auto-discovery", [r"opt.in", r"Docker", r"host networking"]), - (r"slow storage", [r"slow.{0,20}storage", r"scan|lock"]), - (r"32-bit builds", [r"32.bit", r"scan"]), - ] - for source in sources: - narration = [ - s["text"] for s in sentences if s["source_id"] == source["source_id"] - ] - body = re.sub(r"[*`]", "", source["body"]) - for trigger, requirements in rules: - if re.search(trigger, body, re.I | re.S) and not any( - all(re.search(term, sentence, re.I) for term in requirements) - for sentence in narration - ): - raise AudioError( - "Narration omits a consequential source qualifier or action" - ) - - -def openai_json(payload): - data, content_type = request( - "https://api.openai.com/v1/responses", os.environ["OPENAI_API_KEY"], payload - ) - if content_type != "application/json": - raise AudioError("Text API returned an unexpected content type") - result = json.loads(data) - if result.get("status") != "completed": - raise AudioError("Text response incomplete or refused") - parts = [ - part - for output in result.get("output", []) - if output.get("type") == "message" - for part in output.get("content", []) - ] - if any(part.get("type") == "refusal" for part in parts): - raise AudioError("Text response refused") - texts = [part["text"] for part in parts if part.get("type") == "output_text"] - if len(texts) != 1: - raise AudioError("Expected one structured narration") - return json.loads(texts[0]), result.get("usage") - - -def load_stage(stage): - manifest, sources = read_json("manifest.json"), read_json("sources.json") - if os.environ.get("AUDIO_ENABLED") != "true" or not os.environ.get( - "OPENAI_API_KEY" - ): - raise AudioError("Paid generation needs explicit enablement and OPENAI_API_KEY") - if config(manifest["mode"]) != manifest["config"]: - raise AudioError("Model configuration changed after preparation") - if manifest["mode"] == "validate" or manifest["requests"][stage] != 0: - raise AudioError("Stage not authorized or already attempted") - recheck_sources(manifest, sources) - if stage == "text" and manifest["status"] != "prepared": - raise AudioError("Script stage requires a fresh prepared attempt") - return manifest, sources - - -def script(): - manifest, sources = load_stage("text") - payload = text_payload(sources, manifest["config"]) - cost_bound(payload, manifest["config"], manifest["mode"]) - manifest["requests"]["text"] = 1 - manifest["status"] = "script_requested" - write_json("manifest.json", manifest) - result, usage = openai_json(payload) - text = validate_script(result, sources) - if len(text.rstrip("\n").encode()) > narration_byte_limit(manifest["config"]): - raise AudioError( - "Narration exceeds the configured speech input limit; review a shorter script" - ) - (OUT / "transcript.txt").write_text(text, encoding="utf-8") - write_json("evidence.json", result) - manifest.update( - status="script_validated", - text_usage=usage, - script_sha256=digest(text.encode()), - word_count=len(text.split()), - character_count=len(text.rstrip("\n")), - ) - write_json("manifest.json", manifest) - - -def speech(): - manifest, sources = load_stage("speech") - if manifest["mode"] != "audio" or manifest["status"] != "script_validated": - raise AudioError("Speech requires a validated audio-mode script") - text = (OUT / "transcript.txt").read_text(encoding="utf-8") - if ( - digest(text.encode()) != manifest["script_sha256"] - or validate_script(read_json("evidence.json"), sources) != text - ): - raise AudioError("Script checkpoint changed") - if ( - not subprocess.run( - ["ffprobe", "-version"], capture_output=True, check=False - ).returncode - == 0 - ): - raise AudioError("ffprobe is required before speech generation") - if ( - not subprocess.run( - ["ffmpeg", "-version"], capture_output=True, check=False - ).returncode - == 0 - ): - raise AudioError("ffmpeg is required before speech generation") - selected = manifest["config"] - payload = { - "model": selected["tts_model"], - "voice": selected["voice"], - "input": text.rstrip("\n"), - "response_format": "mp3", - "speed": selected["speed"], - } - if selected["speech_instructions"]: - payload["instructions"] = selected["speech_instructions"] - # Mini TTS accepts at most 2000 input tokens. No dependency/tokenizer: - # use UTF-8 bytes as a conservative upper bound and fail without truncation. - if ( - len((payload["input"] + payload["instructions"]).encode()) - > MINI_TTS_INPUT_BYTES - ): - raise AudioError( - "Mini TTS conservative input-token limit exceeded; review a shorter script" - ) - manifest["requests"]["speech"] = 1 - manifest["status"] = "speech_requested" - write_json("manifest.json", manifest) - data, content_type = request( - "https://api.openai.com/v1/audio/speech", - os.environ["OPENAI_API_KEY"], - payload, - limit=10485760, - ) - if ( - content_type not in {"audio/mpeg", "audio/mp3", "application/octet-stream"} - or not data - ): - raise AudioError("Speech API did not return audio") - temporary = OUT / "audio.tmp" - temporary.write_bytes(data) - try: - probe = subprocess.run( - [ - "ffprobe", - "-v", - "error", - "-show_entries", - "format=duration:stream=codec_name", - "-of", - "json", - str(temporary), - ], - check=True, - capture_output=True, - text=True, - timeout=30, - ) - info = json.loads(probe.stdout) - duration = float(info["format"]["duration"]) - if ( - not math.isfinite(duration) - or duration <= 0 - or not info["streams"] - or any(s["codec_name"] != "mp3" for s in info["streams"]) - ): - raise AudioError("Speech response is not a valid nonempty MP3") - subprocess.run( - [ - "ffmpeg", - "-v", - "error", - "-xerror", - "-i", - str(temporary), - "-f", - "null", - "-", - ], - check=True, - capture_output=True, - timeout=30, - ) - temporary.replace(OUT / "release-audio.mp3") - finally: - temporary.unlink(missing_ok=True) - manifest.update( - status="audio_validated", - duration_seconds=duration, - audio_sha256=digest(data), - duration_needs_review=not 105 <= duration <= 145, - ) - write_json("manifest.json", manifest) - summary( - f"MP3 validated: {duration:.1f} seconds. AI-generated voice. Listen before distribution; duration target is 105-145 seconds." - ) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("stage", choices=["prepare", "script", "speech"]) - args = parser.parse_args() - try: - {"prepare": prepare, "script": script, "speech": speech}[args.stage]() - except ( - AudioError, - KeyError, - ValueError, - TypeError, - AttributeError, - OSError, - subprocess.SubprocessError, - ) as exc: - # Unexpected exceptions are intentionally not printed: remote content - # and a credential must never appear in logs or workflow commands. - print( - "Release audio failed: " - + ( - str(exc) - if isinstance(exc, AudioError) - else "invalid data or unavailable local tool" - ), - file=sys.stderr, - ) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/release/audio/test_release_audio.py b/release/audio/test_release_audio.py deleted file mode 100644 index 04912f126..000000000 --- a/release/audio/test_release_audio.py +++ /dev/null @@ -1,655 +0,0 @@ -"""Offline acceptance tests. Unmocked network access is always an error.""" - -import copy -import json -import os -import shutil -import subprocess -import tempfile -import unittest -import urllib.error -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - -import release_audio as audio - -RECORDS = json.loads( - Path(__file__).with_name("testdata").joinpath("releases.json").read_text() -) -SOURCES = [audio.normalize_release(record) for record in RECORDS] - - -def narration(): - lines = [ - ( - 0, - "Version 0.64.0 introduced experimental Jellyfin music support, which must be explicitly enabled.", - ), - ( - 0, - "Back up your database before upgrading because internal IDs change; clients may need to resync cached IDs.", - ), - ( - 0, - "Plugin authors must migrate to the host HTTP service and review restrictions on private or loopback network addresses.", - ), - ( - 0, - "Shares now belong to their creator, and admins cannot create them for another user.", - ), - ( - 0, - "Negative configuration durations are rejected at startup, and unknown options produce warnings.", - ), - ( - 0, - "Security fixes protect library access and plugin networking, alongside improvements to artwork, sorting and playlist imports.", - ), - ( - 0, - "Database restore also avoids wiping existing data when the backup file is missing.", - ), - ( - 1, - "Version 0.64.1 is a security release fixing five vulnerabilities; upgrade as soon as practical.", - ), - ( - 1, - "Jellyfin client compatibility improves, and Quick Connect makes signing in easier.", - ), - ( - 1, - "Local discovery is opt-in, and Docker users need host networking for discovery broadcasts.", - ), - ( - 1, - "Smart playlists can reference another playlist by path, while the interface follows your selected language for dates.", - ), - ( - 2, - "Version 0.64.2 fixes scan failures and database lock contention on slow storage.", - ), - (2, "It also fixes scans on 32-bit builds with invalid track metadata."), - ( - 2, - "Security fixes sanitize download names and prevent an admin password from reaching logs.", - ), - ] - result = { - "sentences": [ - { - "text": text, - "source_id": SOURCES[index]["source_id"], - "excerpt": SOURCES[index]["body"][:40], - } - for index, text in lines - ], - "cautions": [], - } - # Short exact excerpts are only mechanical evidence in this fake response. - # Human review is still needed for entailment; qualifier tests below ensure - # consequential conditions cannot disappear just by filling the evidence map. - for caution in audio.cautions(SOURCES): - index = next( - i - for i, s in enumerate(result["sentences"]) - if s["source_id"] == caution["source_id"] - ) - result["cautions"].append( - {"caution_id": caution["caution_id"], "sentence_index": index} - ) - return result - - -class ReleaseAudioTests(unittest.TestCase): - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - self.out = Path(self.tmp.name) - self.patch(audio, "OUT", new=self.out) - self.patch( - audio.urllib.request, - "build_opener", - side_effect=AssertionError("Unmocked network access"), - ) - self.env = { - "AUDIO_ENABLED": "true", - "AUDIO_KEY_CONFIGURED": "true", - "AUDIO_TEXT_MODEL": "gpt-6-luna", - "AUDIO_TTS_MODEL": "gpt-4o-mini-tts-2025-12-15", - "AUDIO_VOICE": "onyx", - "OPENAI_API_KEY": "test-never-a-real-key", - "GH_TOKEN": "offline", - "GITHUB_REPOSITORY": audio.REPOSITORY, - "GITHUB_EVENT_NAME": "workflow_dispatch", - "GITHUB_REF": "refs/heads/master", - "GITHUB_RUN_ID": "123", - "GITHUB_RUN_ATTEMPT": "1", - "GITHUB_OUTPUT": str(self.out / "outputs"), - "GITHUB_STEP_SUMMARY": str(self.out / "summary"), - "GITHUB_EVENT_PATH": str(self.out / "event.json"), - } - env_patch = patch.dict(os.environ, self.env) - env_patch.start() - self.addCleanup(env_patch.stop) - self.event = { - "repository": {"default_branch": "master"}, - "inputs": {"tags": "v0.64.2,v0.64.0,v0.64.1", "mode": "validate"}, - } - self.save_event() - - def patch(self, target, name, **kwargs): - p = patch.object(target, name, **kwargs) - value = p.start() - self.addCleanup(p.stop) - return value - - def save_event(self): - Path(self.env["GITHUB_EVENT_PATH"]).write_text(json.dumps(self.event)) - - def fake_github(self, path): - if path.startswith("/actions/artifacts"): - return {"artifacts": []} - for record in RECORDS: - if path in ( - "/releases/tags/" + record["tag_name"], - f"/releases/{record['id']}", - ): - return copy.deepcopy(record) - raise AssertionError(path) - - def prepare(self, mode="audio"): - self.event["inputs"]["mode"] = mode - self.save_event() - self.patch(audio, "github", side_effect=self.fake_github) - audio.prepare() - - def test_tags_are_bounded_and_sorted(self): - self.assertEqual( - audio.parse_tags("v0.64.2, v0.64.0,v0.64.1"), - ["v0.64.0", "v0.64.1", "v0.64.2"], - ) - for raw in ( - "", - "v0.64.0,", - "v0.64.0,v0.64.0", - "v1.0.0,v2.0.0,v3.0.0,v4.0.0", - "$(touch /tmp/pwn)", - "../../foo", - "v1.0.0\nmalicious", - "v01.2.3", - ): - with self.subTest(raw=raw), self.assertRaises(audio.AudioError): - audio.parse_tags(raw) - - def test_ineligible_releases(self): - for values in ( - {"draft": True}, - {"prerelease": True}, - {"body": " "}, - {"published_at": None}, - {"id": -1}, - {"id": True}, - {"body": "x" * 65537}, - {"tag_name": "v0.64.0,v0.64.1"}, - {"tag_name": "v1.2.3١"}, - ): - record = dict(RECORDS[0], **values) - with self.subTest(values=list(values)), self.assertRaises(audio.AudioError): - audio.normalize_release(record) - - def test_manual_prerelease_opt_in(self): - record = dict(RECORDS[0], prerelease=True, tag_name="v0.64.0-rc.1") - self.assertTrue(audio.normalize_release(record, True)["prerelease"]) - - def test_actual_prototype_qualifiers_are_detected(self): - text = " ".join(c["excerpt"] for c in audio.cautions(SOURCES)) - for term in ( - "back up your database", - "re-sync", - "Plugin authors", - "experimental", - "security release", - "opt-in", - "host networking", - ): - self.assertIn(term, text) - - def test_source_warnings_after_footer_are_preserved(self): - source = dict( - SOURCES[0], - body="Notes\n## Helping out\nThanks\n## Migration\n- Back up before upgrade.", - ) - self.assertIn("Back up", audio.source_input([source])[0]["body"]) - self.assertTrue(audio.cautions([source])) - - def test_validate_mode_never_contacts_openai(self): - self.prepare("validate") - self.assertIn("generate=false", (self.out / "outputs").read_text()) - self.assertEqual( - audio.read_json("manifest.json")["requests"], {"text": 0, "speech": 0} - ) - - def test_default_branch_and_repository_required(self): - for values in ( - {"GITHUB_REF": "refs/heads/untrusted"}, - {"GITHUB_REPOSITORY": "attacker/navidrome"}, - ): - with ( - self.subTest(values=values), - patch.dict(os.environ, values), - self.assertRaises(audio.AudioError), - ): - audio.prepare() - - def test_published_event_uses_exact_release_id(self): - self.event = { - "action": "published", - "release": RECORDS[2], - "repository": {"default_branch": "master"}, - } - self.save_event() - calls = self.patch(audio, "github", side_effect=self.fake_github) - with patch.dict(os.environ, {"GITHUB_EVENT_NAME": "release"}): - audio.prepare() - self.assertEqual( - calls.call_args_list[0].args[0], f"/releases/{RECORDS[2]['id']}" - ) - self.assertEqual(audio.read_json("sources.json")[0]["tag"], "v0.64.2") - - def test_paid_generation_requires_explicit_configuration(self): - for values in ( - {"AUDIO_TEXT_MODEL": "unknown"}, - {"AUDIO_TTS_MODEL": "unknown"}, - {"AUDIO_VOICE": "custom-voice"}, - ): - with ( - self.subTest(values=values), - patch.dict(os.environ, values), - self.assertRaises(audio.AudioError), - ): - audio.config("audio") - with ( - patch.dict(os.environ, {"AUDIO_ENABLED": "false"}), - self.assertRaises(audio.AudioError), - ): - self.prepare() - with ( - patch.dict(os.environ, {"AUDIO_KEY_CONFIGURED": "false"}), - self.assertRaises(audio.AudioError), - ): - self.prepare() - - def test_price_and_input_bounds(self): - selected = audio.config("audio") - payload = audio.text_payload(SOURCES, selected) - self.assertLess(audio.cost_bound(payload, selected, "audio"), 0.10) - self.assertEqual(payload["reasoning"], {"effort": "low"}) - self.assertFalse(payload["store"]) - self.assertNotIn("tools", payload) - with self.assertRaises(audio.AudioError): - audio.cost_bound(dict(payload, input="x" * 65537), selected, "audio") - with ( - patch.object(audio, "MAX_COST_USD", 0.001), - self.assertRaises(audio.AudioError), - ): - audio.cost_bound(payload, selected, "audio") - - def test_duplicate_attempt_and_expiry(self): - for expired in (False, True): - with patch.object( - audio, - "github", - return_value={"artifacts": [{"name": "key-123-1", "expired": expired}]}, - ): - self.assertEqual(audio.duplicate("key"), not expired) - - def test_duplicate_lookup_paginates_and_fails_closed(self): - page = {"artifacts": [{"name": "other", "expired": False}] * 100} - with patch.object( - audio, "github", side_effect=[page, {"artifacts": []}] - ) as lookup: - self.assertFalse(audio.duplicate("key")) - self.assertEqual(lookup.call_count, 2) - with ( - patch.object(audio, "github", return_value=page), - self.assertRaises(audio.AudioError), - ): - audio.duplicate("key") - - def test_duplicate_is_skipped_unless_manually_forced(self): - self.event["inputs"]["mode"] = "audio" - self.save_event() - self.patch(audio, "github", side_effect=self.fake_github) - with patch.object(audio, "duplicate", return_value=True): - audio.prepare() - self.assertEqual(audio.read_json("manifest.json")["status"], "duplicate") - self.event["inputs"]["force_regenerate"] = "true" - self.save_event() - audio.prepare() - self.assertEqual(audio.read_json("manifest.json")["status"], "prepared") - - def test_accepts_grounded_prototype(self): - text = audio.validate_script(narration(), SOURCES) - self.assertIn("AI-generated voice", text) - self.assertIn("0.64.2", text) - - def test_missing_evidence_or_cautions_are_rejected(self): - for mutation in ( - lambda r: r["sentences"][0].update(source_id="unknown"), - lambda r: r["sentences"][0].update(excerpt="invented unsupported excerpt"), - lambda r: r["cautions"].pop(), - lambda r: r["cautions"][0].update(sentence_index=999), - ): - result = narration() - mutation(result) - with self.subTest(mutation=mutation), self.assertRaises(audio.AudioError): - audio.validate_script(result, SOURCES) - - def test_security_migration_and_opt_in_omissions_are_rejected(self): - for term in ( - "Back up", - "resync", - "experimental", - "host HTTP", - "opt-in", - "host networking", - "32-bit", - "slow storage", - ): - result = narration() - for sentence in result["sentences"]: - sentence["text"] = sentence["text"].replace(term, "some detail") - with self.subTest(term=term), self.assertRaises(audio.AudioError): - audio.validate_script(result, SOURCES) - - def test_output_injection_and_length_are_rejected(self): - for text in ( - " https://evil.example", - " `code`", - " $(cat secret)", - "