feat(release): replace audio helper with Go podcast CLI

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-10-01 18:47:10 -04:00
parent e3fde1fd74
commit 82eea674a5
15 changed files with 2277 additions and 1635 deletions

View File

@ -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

View File

@ -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

View File

@ -1 +0,0 @@
__pycache__/

View File

@ -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)

View File

@ -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())

View File

@ -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)",
" <script>",
" @handle",
"x" * 2501,
):
result = narration()
result["sentences"][0]["text"] += text
with self.subTest(text=text[:30]), self.assertRaises(audio.AudioError):
audio.validate_script(result, SOURCES)
def test_source_edits_stop_before_paid_call(self):
self.prepare()
manifest = audio.read_json("manifest.json")
changed = dict(RECORDS[0], body=RECORDS[0]["body"] + "\nNew warning")
with (
patch.object(audio, "github", return_value=changed),
self.assertRaises(audio.AudioError),
):
audio.recheck_sources(manifest, SOURCES)
def test_script_invalid_output_never_produces_transcript(self):
self.prepare()
self.patch(
audio, "openai_json", return_value=({"sentences": [], "cautions": []}, {})
)
with self.assertRaises(audio.AudioError):
audio.script()
self.assertFalse((self.out / "transcript.txt").exists())
self.assertEqual(audio.read_json("manifest.json")["requests"]["text"], 1)
with self.assertRaises(audio.AudioError):
audio.script()
def test_script_checkpoint_and_speech_response_validation(self):
self.prepare()
self.patch(
audio, "openai_json", return_value=(narration(), {"input_tokens": 1000})
)
audio.script()
self.patch(
audio.subprocess, "run", return_value=type("Probe", (), {"returncode": 0})()
)
call = self.patch(
audio, "request", return_value=(b'{"error":"bad"}', "application/json")
)
with self.assertRaises(audio.AudioError):
audio.speech()
self.assertFalse((self.out / "release-audio.mp3").exists())
self.assertEqual(call.call_count, 1)
self.assertEqual(
call.call_args.args[2]["instructions"], audio.SPEECH_INSTRUCTIONS
)
def test_tampered_checkpoint_rejected_before_speech(self):
self.prepare()
self.patch(audio, "openai_json", return_value=(narration(), {}))
audio.script()
(self.out / "transcript.txt").write_text("tampered")
with self.assertRaises(audio.AudioError):
audio.speech()
def test_mini_tts_input_limit(self):
self.prepare()
self.patch(audio, "openai_json", return_value=(narration(), {}))
audio.script()
self.patch(
audio.subprocess, "run", return_value=type("Probe", (), {"returncode": 0})()
)
with patch.object(audio, "SPEECH_INSTRUCTIONS", "x" * 3000):
manifest = audio.read_json("manifest.json")
manifest["config"]["speech_instructions"] = audio.SPEECH_INSTRUCTIONS
audio.write_json("manifest.json", manifest)
with self.assertRaises(audio.AudioError):
audio.speech()
def valid_script(self):
self.prepare()
self.patch(audio, "openai_json", return_value=(narration(), {}))
audio.script()
def test_valid_mp3_is_saved_with_checksums_and_duration(self):
self.valid_script()
probe = {"format": {"duration": "120.5"}, "streams": [{"codec_name": "mp3"}]}
self.patch(
audio.subprocess,
"run",
return_value=SimpleNamespace(returncode=0, stdout=json.dumps(probe)),
)
self.patch(audio, "request", return_value=(b"mock-mp3-content", "audio/mpeg"))
audio.speech()
manifest = audio.read_json("manifest.json")
self.assertEqual(manifest["status"], "audio_validated")
self.assertEqual(manifest["duration_seconds"], 120.5)
self.assertFalse(manifest["duration_needs_review"])
self.assertEqual(
manifest["audio_sha256"],
audio.digest((self.out / "release-audio.mp3").read_bytes()),
)
self.assertFalse((self.out / "audio.tmp").exists())
with self.assertRaises(audio.AudioError):
audio.speech()
def test_invalid_mp3_metadata_never_becomes_an_artifact(self):
self.valid_script()
for duration, codec in (("nan", "mp3"), ("0", "mp3"), ("120", "aac")):
manifest = audio.read_json("manifest.json")
manifest.update(
status="script_validated", requests={"text": 1, "speech": 0}
)
audio.write_json("manifest.json", manifest)
probe = {
"format": {"duration": duration},
"streams": [{"codec_name": codec}],
}
with (
patch.object(
audio.subprocess,
"run",
return_value=SimpleNamespace(
returncode=0, stdout=json.dumps(probe)
),
),
patch.object(audio, "request", return_value=(b"invalid", "audio/mpeg")),
):
with (
self.subTest(duration=duration, codec=codec),
self.assertRaises(audio.AudioError),
):
audio.speech()
self.assertFalse((self.out / "release-audio.mp3").exists())
self.assertFalse((self.out / "audio.tmp").exists())
@unittest.skipUnless(
shutil.which("ffmpeg") and shutil.which("ffprobe"), "ffmpeg/ffprobe unavailable"
)
def test_real_mp3_decode_with_mocked_openai(self):
# Synthetic local tone, not a paid narration or an auditioned voice.
tone = self.out / "tone.mp3"
subprocess.run(
[
"ffmpeg",
"-v",
"error",
"-f",
"lavfi",
"-i",
"sine=frequency=440",
"-t",
"0.2",
"-c:a",
"libmp3lame",
str(tone),
],
check=True,
capture_output=True,
timeout=30,
)
self.valid_script()
self.patch(audio, "request", return_value=(tone.read_bytes(), "audio/mpeg"))
audio.speech()
manifest = audio.read_json("manifest.json")
self.assertGreater(manifest["duration_seconds"], 0)
self.assertTrue(manifest["duration_needs_review"])
self.assertEqual(manifest["requests"], {"text": 1, "speech": 1})
def test_mp3_decode_failure_cleans_temporary_file(self):
self.valid_script()
def run(args, **kwargs):
if args[0] == "ffmpeg" and "-xerror" in args:
raise subprocess.CalledProcessError(1, args, stderr=b"untrusted-data")
probe = {"format": {"duration": "120"}, "streams": [{"codec_name": "mp3"}]}
return SimpleNamespace(returncode=0, stdout=json.dumps(probe))
self.patch(audio.subprocess, "run", side_effect=run)
self.patch(audio, "request", return_value=(b"broken-mp3", "audio/mpeg"))
with self.assertRaises(subprocess.CalledProcessError):
audio.speech()
self.assertFalse((self.out / "release-audio.mp3").exists())
self.assertFalse((self.out / "audio.tmp").exists())
def test_config_change_after_prepare_is_rejected(self):
self.prepare()
with (
patch.dict(os.environ, {"AUDIO_VOICE": "cedar"}),
self.assertRaises(audio.AudioError),
):
audio.script()
def test_legacy_tts_does_not_receive_instructions(self):
with patch.dict(os.environ, {"AUDIO_TTS_MODEL": "tts-1"}):
self.valid_script()
self.patch(
audio.subprocess, "run", return_value=SimpleNamespace(returncode=0)
)
call = self.patch(
audio, "request", return_value=(b"error", "application/json")
)
with self.assertRaises(audio.AudioError):
audio.speech()
self.assertNotIn("instructions", call.call_args.args[2])
def test_responses_refusal_and_incomplete_are_rejected(self):
for response in (
{"status": "incomplete"},
{
"status": "completed",
"output": [{"type": "message", "content": [{"type": "refusal"}]}],
},
):
with (
patch.object(
audio,
"request",
return_value=(json.dumps(response).encode(), "application/json"),
),
self.assertRaises(audio.AudioError),
):
audio.openai_json({})
def test_complete_structured_response_is_parsed(self):
expected = narration()
response = {
"status": "completed",
"usage": {"input_tokens": 500},
"output": [
{"type": "reasoning"},
{
"type": "message",
"content": [{"type": "output_text", "text": json.dumps(expected)}],
},
],
}
with patch.object(
audio,
"request",
return_value=(json.dumps(response).encode(), "application/json"),
):
result, usage = audio.openai_json({})
self.assertEqual(result, expected)
self.assertEqual(usage, {"input_tokens": 500})
def test_response_size_limit_and_credentials_not_in_payload(self):
response = SimpleNamespace(
headers=SimpleNamespace(get_content_type=lambda: "audio/mpeg")
)
response.read = lambda limit: b"x" * limit
context = unittest.mock.MagicMock()
context.__enter__.return_value = response
opener = unittest.mock.MagicMock()
opener.open.return_value = context
with (
patch.object(audio.urllib.request, "build_opener", return_value=opener),
self.assertRaises(audio.AudioError),
):
audio.request(
"https://api.openai.com/v1/audio/speech",
"test-secret",
{"input": "public text"},
limit=100,
)
req = opener.open.call_args.args[0]
self.assertNotIn(b"test-secret", req.data)
self.assertEqual(req.get_header("Authorization"), "Bearer test-secret")
def test_http_errors_and_timeouts_are_not_retried_or_leaked(self):
for error in (
urllib.error.HTTPError("url", 429, "secret-body", {}, None),
TimeoutError("secret-body"),
):
opener = type("Opener", (), {})()
with (
patch.object(audio.urllib.request, "build_opener", return_value=opener),
patch.object(opener, "open", create=True, side_effect=error) as call,
):
with self.assertRaises(audio.AudioError) as caught:
audio.request("https://api.openai.com/v1/responses", "secret")
self.assertNotIn("secret", str(caught.exception))
self.assertEqual(call.call_count, 1)
def test_redirects_are_disabled(self):
self.assertIsNone(audio.NoRedirect().redirect_request(None))
if __name__ == "__main__":
unittest.main()

232
release/podcast/README.md Normal file
View File

@ -0,0 +1,232 @@
# Release podcast
A standalone Go CLI turns **published Navidrome release notes** into a grounded
English recap and an MP3. The local CLI and GitHub workflow share the same source
resolution, evidence checks, cost limits and generation stages. Outputs are
`transcript.txt`, `release-podcast.mp3`, `sources.json`, `evidence.json` and
`manifest.json`. Nothing is uploaded to release assets or social media.
## Local use, including before merge
From a checkout of this PR, with the Go version from `go.mod`:
```sh
go run ./release/podcast --from 0.64.0 --to 0.64.2 \
--dry-run --output /tmp/navidrome-podcast-validation
```
This resolves the inclusive range of published releases, writes exact sources
and a manifest, and makes **zero OpenAI requests**. It needs GitHub network
access; `GH_TOKEN` is optional for public notes and increases the rate limit.
It needs neither an OpenAI key nor repository variables, and does not spoof
GitHub context. Explicit versions are also supported:
```sh
go run ./release/podcast --tags v0.64.0,v0.64.1,v0.64.2 \
--mode validate --output /tmp/navidrome-podcast-validation
```
Select at most three distinct releases. Versions may omit the `v` prefix.
Ranges require ordered stable-version endpoints that both exist as published
releases. Listing is bounded to 1,000 release records; larger listings require
explicit `--tags`. Published prereleases require `--include-prereleases` and
explicit tags if they are range endpoints. Drafts are discarded.
When you separately decide to incur API usage, make `OPENAI_API_KEY` available
in your local process environment through your own secure setup. **Never put
the key in a CLI argument, log, commit, or transcript.** A repository Actions
secret is not a local environment variable; this tool does not retrieve it.
Choose supported values for the nonsecret `TEXT_MODEL`, `TTS_MODEL` and `VOICE`
variables used in this example:
```sh
go run ./release/podcast --from 0.64.0 --to 0.64.2 \
--mode audio --allow-paid \
--text-model "$TEXT_MODEL" --tts-model "$TTS_MODEL" --voice "$VOICE" \
--output /tmp/navidrome-podcast-preview
```
Install `ffmpeg` (including `ffprobe`) yourself before local audio mode. Media
preflight runs before any paid request. Then listen to
`/tmp/navidrome-podcast-preview/release-podcast.mp3` and compare the transcript
and evidence with the notes. Local preview does **not** require merging the PR
or setting `RELEASE_AUDIO_ENABLED`.
`--mode script --allow-paid --text-model MODEL` generates only the transcript
and evidence. `--dry-run` always forces validation, even if `--mode audio` or
`--allow-paid` is also present. CLI model/voice flags override `AUDIO_TEXT_MODEL`,
`AUDIO_TTS_MODEL` and `AUDIO_VOICE` environment values; there are no model/voice
defaults. No API host, repository, key, shell command or pricing override is
accepted as a CLI option.
Use a fresh output directory for each intentional preview. Paid runs acquire
an exclusive local `.release-podcast.lock` and persist a reservation in
`.attempts/` **before** contacting OpenAI. Failed attempts also remain reserved.
`--force` permits another paid attempt and replacement of generated files;
it cannot bypass an active lock. After a killed process, review the output and
ledger before manually removing a stale lock. Validation refuses directories
containing generated script/audio files so it cannot relabel older audio.
Local deduplication is scoped to the output directory; a new directory or deleted
ledger can permit another charge. Script and audio modes have distinct keys.
## Models and voice
These are supported choices, **not finalized user defaults**:
| 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 a male-style delivery; perceived
voice gender is subjective. Mini TTS receives fixed calm English instructions;
legacy TTS does not. Unsupported models fail before paid requests. A new model
requires a code/pricing review. Luna is an API alias whose behavior can change;
the other script model and Mini TTS use pinned snapshots.
## GitHub Actions setup and publication
The workflow is `.github/workflows/release-podcast.yml`, named **Release
podcast**. Automatic generation is disabled by default. The secret and variable
names already communicated during planning are retained, so no configuration
rename is required:
1. The maintainer adds repository Actions secret `OPENAI_API_KEY` personally.
One project key covers Responses and speech when its model/endpoint access
allows both. Use a dedicated project key and usage alerts.
2. Choose repository variables `RELEASE_AUDIO_TEXT_MODEL`,
`RELEASE_AUDIO_TTS_MODEL` and `RELEASE_AUDIO_VOICE`.
3. Set `RELEASE_AUDIO_ENABLED` to literal `true` only when paid workflow runs
are authorized. Blank variables are fine for offline tests/manual validation.
4. After merge, dispatch **Release podcast** from `master`, starting with
`validate` and `v0.64.0,v0.64.1,v0.64.2`. Models and voice come from repository
variables, not manual inputs. No named GitHub Environment is configured.
The automatic trigger is **`release: published`**, stable releases only. It
fetches the event's exact release ID, never `/releases/latest`. Tags, edits,
drafts and prereleases do not automatically generate audio. Promotion of an
already-published prerelease may require manual dispatch; manual prereleases
need explicit opt-in.
The existing `pipeline.yml` uses `GITHUB_TOKEN` for GoReleaser and
`release/goreleaser.yml` sets `draft: true`. Those behaviors are unchanged.
Maintainer publication of the draft through GitHub can trigger this workflow;
publication with `GITHUB_TOKEN` generally suppresses downstream release events.
Use manual dispatch after such publication, or separately review an explicit
dispatch integration with narrow Actions permissions. Do not add a broad PAT
or change draft publishing to solve this integration.
Land the workflow before the next tag. Release events are associated with the
tagged commit; historical tags cannot be assumed to contain a new workflow.
Manual dispatch requires the workflow on the default branch and deliberately
rejects other branches. Checkout executes the reviewed default-branch helper,
with credentials not persisted. **Use the first-class local CLI to test the PR
before merge**, rather than bypassing this Actions guard.
The workflow builds the shared Go binary and installs media tools in
credential-free steps. It exposes `OPENAI_API_KEY` only to generation steps,
grants `contents: read` and `actions: read`, and pins actions to verified SHAs.
No PR event can run the paid workflow. The offline PR test workflow has no key.
| Mode | Maximum OpenAI requests | Outputs |
| --- | --- | --- |
| `validate` / `--dry-run` | None | Sources and manifest; modeled text estimate if configured |
| `script` | One Responses request | Transcript, evidence, sources and manifest |
| `audio` | One Responses plus one speech request | Script outputs and validated MP3 |
## Limits, grounding and costs
Target about two minutes: 250–280 total words and 105–145 seconds. Small releases
may be shorter; correctness takes priority over filler. There are no application
or SDK retries/repair calls. Timeouts may already be billed. HTTP requests have
a one-minute timeout, media inspection 30 seconds, and the CLI/workflow ten
minutes. Sources and the complete prompt each have separate 64 KiB caps;
oversized material fails without truncating warnings. Text output is capped at
3,000 tokens including reasoning/evidence. Narration is at most 280 words and
2,500 characters. Mini TTS additionally uses a conservative 2,000 UTF-8-byte
input ceiling including instructions, to stay below its input-token limit.
The model has no tools and receives only public notes as untrusted evidence,
not executable instructions. No embedded links or draft advisories are fetched.
Strict JSON maps sentences to exact source excerpts and required cautions.
IDs/excerpts and migration/security coverage must match; lexical checks retain
the prototype's backup, client resync, experimental/opt-in, plugin networking,
Docker discovery, and slow-storage/32-bit scan cautions. These checks do not
prove semantic entailment or classify language perfectly. **Human factual and
listening review remains required.** Every transcript discloses the AI voice.
`store: false` does not imply zero provider retention.
Reviewed prices, USD per million units, checked 2026-10-01:
| 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 allowance above $0.10**, using all serialized
prompt bytes plus 1,024 framing tokens and maximum text output. Legacy TTS uses
the character cap. Mini TTS uses a conservative **6,000 audio-token allowance**
plus input; its API offers no enforceable output-token/dollar cap, so this is
an estimate, **not a billing guarantee**. Prices, anomalous audio duration,
taxes, GitHub usage and deliberate later runs can change costs. Project budget
alerts are soft thresholds. The original approximately $0.03 example applied
to illustrative 4.1 mini + TTS-1 inputs, not every supported model combination.
Neither this PR nor an estimate authorizes a paid prototype run.
## Actions duplicate attempts and recovery
Jobs serialize separately from the build pipeline. Before paid requests, a
bounded lookup of up to 10,000 repository artifacts fails closed on errors or
overflow. A reservation artifact is uploaded first, keyed by repository, sorted
release IDs and mode. Matching attempts are skipped even after failure or
source/model edits; explicit manual `force_regenerate=true` permits another
paid attempt. Rollups and single releases are different source sets.
Reservations last 90 days, review outputs/script checkpoints 30 days, limited
by repository policy. Deleted/expired artifacts permit another attempt, so
this is best-effort deduplication, not a permanent billing ledger. GitHub can
replace an older pending concurrency run; dispatch it manually if needed.
Both paid stages re-fetch notes and stop if changed/withdrawn. Scripts are
checkpointed before TTS. Invalid scripts never reach speech, and error JSON
never becomes MP3. `ffprobe` checks codec/duration and `ffmpeg` decodes the full
file, restricted to MP3 and local file/pipe protocols. Duration deviations are
flagged without regeneration. Manifests record source/prompt/script/audio hashes,
the generation fingerprint, commits, config, request counts, text usage and
duration. Child media/git commands do not inherit API credentials.
Do not force regeneration to fix delivery. If the runner/checkpoint is lost,
automatic cross-run checkpoint recovery is not implemented: review the saved
script and decide whether a new deliberate attempt is warranted. It may be
billed. Actions artifacts expire and require signed-in repository read access;
they are not permanent anonymous podcast URLs.
## Offline verification
```sh
go test -race -count=1 -v ./release/podcast
```
HTTP transports are mocked and unexpected requests fail. Public v0.64.0/.1/.2
fixtures test omissions, unsafe versions/output, inclusive ranges, paid guards,
local locking/reservations, changed sources/checkpoints, Actions trust/config,
artifact expiry/lookup limits, budget caps, redirects and no retries. Real MP3
decoding uses a local synthetic tone with mocked OpenAI. CI installs media
tools so that test runs rather than skips. The tool uses Go's standard library;
no Python implementation or additional Go module dependency is required.
## 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 guide](https://developers.openai.com/api/docs/guides/text-to-speech)

176
release/podcast/actions.go Normal file
View File

@ -0,0 +1,176 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
)
type event struct {
Action string `json:"action"`
Release releaseRecord `json:"release"`
Repository struct {
DefaultBranch string `json:"default_branch"`
} `json:"repository"`
Inputs struct {
Tags string `json:"tags"`
Mode string `json:"mode"`
IncludePrereleases string `json:"include_prereleases"`
Force string `json:"force_regenerate"`
} `json:"inputs"`
}
func githubEvent() (event, error) {
var ev event
if os.Getenv("GITHUB_ACTIONS") != "true" || os.Getenv("GITHUB_REPOSITORY") != repository {
return ev, errors.New("Actions entrypoint requires the navidrome repository context")
}
data, err := os.ReadFile(os.Getenv("GITHUB_EVENT_PATH")) // #nosec G703 -- event path is provided by the verified GitHub runner context, never release data.
if err != nil || len(data) > 8<<20 || json.Unmarshal(data, &ev) != nil {
return ev, errors.New("invalid Actions event")
}
switch os.Getenv("GITHUB_EVENT_NAME") {
case "workflow_dispatch":
if ev.Repository.DefaultBranch == "" || os.Getenv("GITHUB_REF") != "refs/heads/"+ev.Repository.DefaultBranch {
return ev, errors.New("manual Actions generation must use the default branch")
}
case "release":
if ev.Action != "published" {
return ev, errors.New("expected a release published event")
}
if _, err := normalizeRelease(ev.Release, false); err != nil {
return ev, err
}
default:
return ev, errors.New("unsupported Actions event")
}
return ev, nil
}
func (e *engine) duplicateArtifact(ctx context.Context, reservation string) (bool, error) {
for page := 1; page <= 100; page++ {
var response struct {
Artifacts []struct {
Name string `json:"name"`
Expired bool `json:"expired"`
} `json:"artifacts"`
}
if err := e.github(ctx, fmt.Sprintf("/actions/artifacts?per_page=100&page=%d", page), &response); err != nil {
return false, err
}
for _, a := range response.Artifacts {
if !a.Expired && (a.Name == reservation || strings.HasPrefix(a.Name, reservation+"-")) {
return true, nil
}
}
if len(response.Artifacts) < 100 {
return false, nil
}
}
return false, errors.New("artifact ledger exceeds lookup limit; manual review required")
}
func actionsOutput(key, value string) error {
f, err := os.OpenFile(os.Getenv("GITHUB_OUTPUT"), os.O_APPEND|os.O_WRONLY, 0600) // #nosec G703 -- append only to the runner-provided Actions output file.
if err != nil {
return errors.New("Actions output file unavailable")
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%s=%s\n", key, value); err != nil {
return errors.New("cannot write Actions output")
}
return nil
}
func (e *engine) runGitHub(ctx context.Context, stage string) error {
ev, err := githubEvent()
if err != nil {
return err
}
switch stage {
case "script":
return e.generateScript(ctx)
case "speech":
return e.generateSpeech(ctx)
case "prepare":
return e.prepareGitHub(ctx, ev)
default:
return errors.New("invalid Actions stage")
}
}
func (e *engine) prepareGitHub(ctx context.Context, ev event) error {
manual := os.Getenv("GITHUB_EVENT_NAME") == "workflow_dispatch"
mode := "audio"
allow, force := false, false
if manual {
mode = ev.Inputs.Mode
if mode == "" {
mode = "validate"
}
allow = ev.Inputs.IncludePrereleases == "true"
force = ev.Inputs.Force == "true"
}
if mode != "validate" && mode != "script" && mode != "audio" {
return errors.New("invalid manual mode")
}
if mode != "validate" && (os.Getenv("AUDIO_ENABLED") != "true" || os.Getenv("AUDIO_KEY_CONFIGURED") != "true") {
return errors.New("paid Actions modes require explicit enablement and OPENAI_API_KEY in Secrets")
}
c, err := reviewedConfig(os.Getenv("AUDIO_TEXT_MODEL"), os.Getenv("AUDIO_TTS_MODEL"), os.Getenv("AUDIO_VOICE"), mode)
if err != nil {
return err
}
if mode == "audio" {
if err := e.mediaTools(ctx); err != nil {
return err
}
}
var sources []source
if manual {
sources, err = e.resolveLocal(ctx, options{tags: ev.Inputs.Tags, includePrereleases: allow})
} else {
var record releaseRecord
err = e.github(ctx, fmt.Sprintf("/releases/%d", ev.Release.ID), &record)
if err == nil {
var s source
s, err = normalizeRelease(record, false)
if err == nil && (s.ID != ev.Release.ID || s.Tag != ev.Release.Tag) {
err = errors.New("release identity changed")
}
sources = []source{s}
}
}
if err != nil {
return err
}
m, err := e.prepare(ctx, sources, c, mode, "github", allow, force)
if err != nil {
return err
}
m.RunID = os.Getenv("GITHUB_RUN_ID")
m.RunAttempt = os.Getenv("GITHUB_RUN_ATTEMPT")
m.EventSHA = os.Getenv("GITHUB_SHA")
if mode != "validate" {
repeated, err := e.duplicateArtifact(ctx, m.Reservation)
if err != nil {
return err
}
if repeated && !force {
m.Status = "duplicate"
}
}
if err := e.saveSources(sources, m); err != nil {
return err
}
if err := actionsOutput("reservation", m.Reservation+"-"+m.RunID+"-"+m.RunAttempt); err != nil {
return err
}
if err := actionsOutput("prepared", "true"); err != nil {
return err
}
return actionsOutput("generate", fmt.Sprint(mode != "validate" && m.Status != "duplicate"))
}

182
release/podcast/main.go Normal file
View File

@ -0,0 +1,182 @@
// release-podcast is a standalone, artifact-only release narration tool.
package main
import (
"context"
"embed"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
//go:embed prompt.txt
var promptFiles embed.FS
const repository = "navidrome/navidrome"
type options struct {
tags, from, to, mode, textModel, ttsModel, voice, output, githubStage string
allowPaid, dryRun, includePrereleases, force bool
}
func parseOptions(args []string, stderr io.Writer) (options, error) {
var o options
f := flag.NewFlagSet("release-podcast", flag.ContinueOnError)
f.SetOutput(stderr)
f.StringVar(&o.tags, "tags", "", "One to three comma-separated versions (v prefix optional)")
f.StringVar(&o.from, "from", "", "Inclusive first published version in a range")
f.StringVar(&o.to, "to", "", "Inclusive last published version in a range")
f.StringVar(&o.mode, "mode", "validate", "validate (free), script, or audio")
f.StringVar(&o.textModel, "text-model", os.Getenv("AUDIO_TEXT_MODEL"), "Reviewed script model; no default")
f.StringVar(&o.ttsModel, "tts-model", os.Getenv("AUDIO_TTS_MODEL"), "Reviewed speech model; no default")
f.StringVar(&o.voice, "voice", os.Getenv("AUDIO_VOICE"), "Compatible stock voice; no default")
f.StringVar(&o.output, "output", "release-podcast", "Output directory (sources, manifest, script and MP3)")
f.BoolVar(&o.dryRun, "dry-run", false, "Force validate mode; never contact OpenAI")
f.BoolVar(&o.allowPaid, "allow-paid", false, "Explicitly permit bounded OpenAI requests for this local invocation")
f.BoolVar(&o.includePrereleases, "include-prereleases", false, "Include published prereleases")
f.BoolVar(&o.force, "force", false, "Permit another paid attempt and replacement of generated files")
f.StringVar(&o.githubStage, "github-stage", "", "Actions entrypoint: prepare, script, or speech")
if err := f.Parse(args); err != nil {
return o, err
}
if f.NArg() != 0 {
return o, errors.New("unexpected positional arguments")
}
if o.dryRun {
o.mode = "validate"
}
if o.mode != "validate" && o.mode != "script" && o.mode != "audio" {
return o, errors.New("mode must be validate, script, or audio")
}
if o.githubStage != "" && (o.tags != "" || o.from != "" || o.to != "" || o.allowPaid || o.dryRun || o.force || o.includePrereleases) {
return o, errors.New("Actions stages use trusted event/environment configuration, not local selection flags")
}
return o, nil
}
type engine struct {
client *http.Client
githubToken, openAIKey, output, prompt string
command func(context.Context, string, ...string) ([]byte, error)
}
func newEngine(output string) (*engine, error) {
abs, err := filepath.Abs(output)
if err != nil {
return nil, errors.New("invalid output directory")
}
prompt, err := promptFiles.ReadFile("prompt.txt")
if err != nil {
return nil, errors.New("embedded prompt unavailable")
}
return &engine{output: abs, prompt: string(prompt), githubToken: os.Getenv("GH_TOKEN"), openAIKey: os.Getenv("OPENAI_API_KEY"),
client: &http.Client{Timeout: time.Minute, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
command: func(ctx context.Context, name string, args ...string) ([]byte, error) {
if name != "git" && name != "ffprobe" && name != "ffmpeg" {
return nil, errors.New("unsupported local tool")
}
cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 -- fixed tool allowlist; arguments are data, never shell source.
for _, item := range os.Environ() {
if !strings.HasPrefix(item, "OPENAI_API_KEY=") && !strings.HasPrefix(item, "GH_TOKEN=") {
cmd.Env = append(cmd.Env, item)
}
}
return cmd.Output()
},
}, nil
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
o, err := parseOptions(args, stderr)
if err != nil {
return err
}
e, err := newEngine(o.output)
if err != nil {
return err
}
if o.githubStage != "" {
return e.runGitHub(ctx, o.githubStage)
}
if os.Getenv("GITHUB_ACTIONS") == "true" {
return errors.New("Actions must use the guarded github-stage entrypoint")
}
return e.runLocal(ctx, o, stdout)
}
func (e *engine) runLocal(ctx context.Context, o options, stdout io.Writer) error {
if o.mode != "validate" && (!o.allowPaid || e.openAIKey == "") {
return errors.New("paid local modes require --allow-paid and OPENAI_API_KEY in the environment")
}
cfg, err := reviewedConfig(o.textModel, o.ttsModel, o.voice, o.mode)
if err != nil {
return err
}
if o.mode == "audio" {
if err := e.mediaTools(ctx); err != nil {
return err
}
}
sources, err := e.resolveLocal(ctx, o)
if err != nil {
return err
}
m, err := e.prepare(ctx, sources, cfg, o.mode, "local", o.includePrereleases, o.force)
if err != nil {
return err
}
if o.mode == "validate" {
if err := e.checkGeneratedFiles(); err != nil {
return err
}
if err := e.saveSources(sources, m); err != nil {
return err
}
_, err = fmt.Fprintln(stdout, "Validated published sources; OpenAI requests: 0. Outputs:", e.output)
return err
}
releaseLock, err := e.reserveLocal(m, o.force)
if err != nil {
return err
}
defer releaseLock()
if o.force {
for _, name := range []string{"transcript.txt", "release-podcast.mp3", "evidence.json"} {
if err := os.Remove(filepath.Join(e.output, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
return errors.New("cannot replace previous generated outputs")
}
}
}
if err := e.saveSources(sources, m); err != nil {
return err
}
if err := e.generateScript(ctx); err != nil {
return err
}
if o.mode == "audio" {
if err := e.generateSpeech(ctx); err != nil {
return err
}
}
_, err = fmt.Fprintln(stdout, "Generated review outputs:", e.output)
return err
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil {
if errors.Is(err, flag.ErrHelp) {
return
}
fmt.Fprintln(os.Stderr, "Release podcast:", err)
os.Exit(1)
}
}

471
release/podcast/pipeline.go Normal file
View File

@ -0,0 +1,471 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"time"
)
const (
maxPromptBytes = 65536
maxOutputTokens = 3000
maxScriptChars = 2500
miniInputBytes = 2000
modeledAudioTokens = 6000
maxCostUSD = 0.10
speechInstructions = "Speak in clear, calm English with a warm, measured delivery. Read API as letters."
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."
)
type modelConfig struct {
TextModel string `json:"text_model"`
TTSModel string `json:"tts_model"`
Voice string `json:"voice"`
Speed float64 `json:"speed"`
Instructions string `json:"speech_instructions"`
}
var textRates = map[string][2]float64{"gpt-6-luna": {0.10, 0.50}, "gpt-4.1-mini-2025-04-14": {0.40, 1.60}}
var speechRates = map[string]float64{"tts-1": 15, "tts-1-hd": 30, "gpt-4o-mini-tts-2025-12-15": 0}
func reviewedConfig(text, tts, voice, mode string) (modelConfig, error) {
c := modelConfig{TextModel: text, TTSModel: tts, Voice: voice, Speed: 1}
if _, ok := textRates[text]; !ok && (mode != "validate" || text != "") {
return c, errors.New("select a reviewed text model; no default is selected")
}
if _, ok := speechRates[tts]; !ok && (mode == "audio" || tts != "") {
return c, errors.New("select a reviewed speech model")
}
voices := ",alloy,echo,fable,onyx,nova,shimmer,"
if tts == "gpt-4o-mini-tts-2025-12-15" {
voices += "ash,ballad,coral,sage,verse,marin,cedar,"
c.Instructions = speechInstructions
}
if (voice != "" || mode == "audio") && (voice == "" || !slices.Contains(strings.Split(strings.Trim(voices, ","), ","), voice)) {
return c, errors.New("select a compatible reviewed stock voice")
}
return c, nil
}
func narrationByteLimit(c modelConfig) int {
if c.Instructions != "" {
return miniInputBytes - len(c.Instructions)
}
return maxScriptChars * 4 // Character-priced models; Unicode still checked separately.
}
type manifest struct {
Version int `json:"version"`
Mode string `json:"mode"`
Origin string `json:"origin"`
Config modelConfig `json:"config"`
Cost *float64 `json:"modeled_cost_usd"`
SourceHash string `json:"source_sha256"`
PromptHash string `json:"prompt_sha256"`
GenerationHash string `json:"generation_sha256"`
ImplementationSHA string `json:"implementation_sha"`
EventSHA string `json:"event_sha,omitempty"`
RunID string `json:"run_id,omitempty"`
RunAttempt string `json:"run_attempt,omitempty"`
CreatedAt string `json:"created_at"`
Reservation string `json:"reservation"`
TextRequests int `json:"text_requests"`
SpeechRequests int `json:"speech_requests"`
IncludePrereleases bool `json:"include_prereleases"`
Force bool `json:"force_regenerate"`
Status string `json:"status"`
ScriptHash string `json:"script_sha256,omitempty"`
AudioHash string `json:"audio_sha256,omitempty"`
Words int `json:"word_count,omitempty"`
Characters int `json:"character_count,omitempty"`
Duration float64 `json:"duration_seconds,omitempty"`
DurationNeedsReview bool `json:"duration_needs_review,omitempty"`
TextUsage json.RawMessage `json:"text_usage,omitempty"`
}
func hashBytes(data []byte) string { h := sha256.Sum256(data); return hex.EncodeToString(h[:]) }
func hashJSON(value any) string { data, _ := json.Marshal(value); return hashBytes(data) }
func (e *engine) textPayload(sources []source, c modelConfig) map[string]any {
input, _ := json.Marshal(map[string]any{"sources": promptSources(sources), "required_cautions": cautions(sources),
"introduction": intro, "closing": closing, "narration_limits": map[string]int{"max_words": 280, "max_characters": maxScriptChars, "max_utf8_bytes": narrationByteLimit(c)}})
p := map[string]any{"model": c.TextModel, "store": false, "max_output_tokens": maxOutputTokens, "instructions": e.prompt, "input": string(input),
"text": map[string]any{"format": map[string]any{"type": "json_schema", "name": "release_narration", "strict": true, "schema": narrationSchema()}}}
if c.TextModel == "gpt-6-luna" {
p["reasoning"] = map[string]string{"effort": "low"}
}
return p
}
func modeledCost(payload any, c modelConfig, mode string) (*float64, error) {
data, err := json.Marshal(payload)
if err != nil {
return nil, errors.New("cannot encode prompt")
}
if len(data) > maxPromptBytes {
return nil, errors.New("complete prompt exceeds byte limit; never truncate warnings")
}
rate, ok := textRates[c.TextModel]
if !ok {
return nil, nil
}
// Conservative byte-level input-token count, plus protocol framing.
cost := (float64(len(data)+1024)*rate[0] + maxOutputTokens*rate[1]) / 1e6
if mode == "audio" {
if c.Instructions != "" {
cost += (float64(miniInputBytes)*0.60 + modeledAudioTokens*12) / 1e6
} else {
cost += maxScriptChars * speechRates[c.TTSModel] / 1e6
}
}
if cost > maxCostUSD {
return nil, errors.New("modeled cost exceeds $0.10; review source size or models")
}
return &cost, nil
}
func (e *engine) prepare(ctx context.Context, sources []source, c modelConfig, mode, origin string, allow, force bool) (manifest, error) {
var m manifest
seen := map[int64]bool{}
total := 0
ids := []int64{}
if len(sources) < 1 || len(sources) > 3 {
return m, errors.New("select one to three releases")
}
for _, s := range sources {
if seen[s.ID] {
return m, errors.New("duplicate release IDs")
}
seen[s.ID] = true
ids = append(ids, s.ID)
total += len(s.Body)
}
if total > maxSourceBytes {
return m, errors.New("combined notes exceed byte limit")
}
cost, err := modeledCost(e.textPayload(sources, c), c, mode)
if err != nil {
return m, err
}
sha, err := e.command(ctx, "git", "rev-parse", "HEAD")
if err != nil {
sha = []byte("unknown")
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
m = manifest{Version: 2, Mode: mode, Origin: origin, Config: c, Cost: cost, SourceHash: hashJSON(sources), PromptHash: hashBytes([]byte(e.prompt)),
ImplementationSHA: strings.TrimSpace(string(sha)), CreatedAt: time.Now().UTC().Format(time.RFC3339), IncludePrereleases: allow, Force: force, Status: "prepared",
Reservation: "release-podcast-attempt-" + hashJSON(map[string]any{"repo": repository, "ids": ids, "mode": mode})}
m.GenerationHash = hashJSON(map[string]any{"sources": sources, "config": c, "prompt": e.prompt, "implementation": m.ImplementationSHA})
return m, nil
}
func (e *engine) writeFile(name string, data []byte) error {
if err := os.MkdirAll(e.output, 0750); err != nil {
return errors.New("cannot create output directory")
}
f, err := os.CreateTemp(e.output, ".release-podcast-*")
if err != nil {
return errors.New("cannot create output file")
}
defer os.Remove(f.Name())
if _, err := f.Write(data); err != nil {
f.Close()
return errors.New("cannot write output")
}
if err := f.Close(); err != nil {
return errors.New("cannot close output")
}
if err := os.Rename(f.Name(), filepath.Join(e.output, name)); err != nil {
return errors.New("cannot install output")
}
return nil
}
func (e *engine) writeJSON(name string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return errors.New("cannot encode output")
}
return e.writeFile(name, append(data, '\n'))
}
func (e *engine) readJSON(name string, target any) error {
data, err := os.ReadFile(filepath.Join(e.output, name))
if err != nil {
return errors.New("required output checkpoint is unavailable")
}
if len(data) > 8<<20 || json.Unmarshal(data, target) != nil {
return errors.New("invalid output checkpoint")
}
return nil
}
func (e *engine) saveSources(sources []source, m manifest) error {
if err := e.writeJSON("sources.json", sources); err != nil {
return err
}
return e.writeJSON("manifest.json", m)
}
func (e *engine) reserveLocal(m manifest, force bool) (func(), error) {
if err := os.MkdirAll(e.output, 0750); err != nil {
return nil, errors.New("cannot create output directory")
}
lockPath := filepath.Join(e.output, ".release-podcast.lock")
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
return nil, errors.New("output directory is locked by another attempt; review a stale lock before removal")
}
lock.Close()
release := func() { _ = os.Remove(lockPath) }
fail := func(err error) (func(), error) { release(); return nil, err }
ledger := filepath.Join(e.output, ".attempts")
if err := os.MkdirAll(ledger, 0750); err != nil {
return fail(errors.New("cannot create local attempt ledger"))
}
attempt := filepath.Join(ledger, m.Reservation+".json")
if !force {
if err := e.checkGeneratedFiles(); err != nil {
return fail(err)
}
}
flags := os.O_CREATE | os.O_EXCL | os.O_WRONLY
if force {
flags = os.O_CREATE | os.O_TRUNC | os.O_WRONLY
}
f, err := os.OpenFile(attempt, flags, 0600)
if err != nil {
return fail(errors.New("paid attempt already reserved or ledger unavailable; explicit --force permits another charge"))
}
data, _ := json.Marshal(m)
_, writeErr := f.Write(data)
closeErr := f.Close()
if writeErr != nil || closeErr != nil {
return fail(errors.New("cannot persist attempt reservation; no paid request made"))
}
return release, nil
}
func (e *engine) checkGeneratedFiles() error {
for _, name := range []string{"transcript.txt", "release-podcast.mp3", "evidence.json"} {
_, err := os.Lstat(filepath.Join(e.output, name))
if err == nil {
return errors.New("generated files already exist; use a fresh output directory or explicit --force in a paid mode")
}
if !errors.Is(err, os.ErrNotExist) {
return errors.New("cannot inspect existing output files")
}
}
return nil
}
func (e *engine) stage(ctx context.Context, kind string) (manifest, []source, error) {
var m manifest
var sources []source
if err := e.readJSON("manifest.json", &m); err != nil {
return m, nil, err
}
if err := e.readJSON("sources.json", &sources); err != nil {
return m, nil, err
}
if e.openAIKey == "" || m.Mode == "validate" {
return m, nil, errors.New("paid stage is not authorized or key is missing")
}
if (m.Origin != "local" && m.Origin != "github") || (os.Getenv("GITHUB_ACTIONS") == "true") != (m.Origin == "github") {
return m, nil, errors.New("checkpoint origin does not match the execution context")
}
if m.Origin == "github" {
if _, err := githubEvent(); err != nil {
return m, nil, err
}
if os.Getenv("AUDIO_ENABLED") != "true" {
return m, nil, errors.New("Actions paid generation is disabled")
}
if m.RunID != os.Getenv("GITHUB_RUN_ID") || m.RunAttempt != os.Getenv("GITHUB_RUN_ATTEMPT") {
return m, nil, errors.New("checkpoint belongs to another Actions attempt")
}
c, err := reviewedConfig(os.Getenv("AUDIO_TEXT_MODEL"), os.Getenv("AUDIO_TTS_MODEL"), os.Getenv("AUDIO_VOICE"), m.Mode)
if err != nil || c != m.Config {
return m, nil, errors.New("configuration changed after preparation")
}
}
if (kind == "text" && (m.TextRequests != 0 || m.Status != "prepared")) || (kind == "speech" && (m.SpeechRequests != 0 || m.Status != "script_validated" || m.Mode != "audio")) {
return m, nil, errors.New("stage already attempted or checkpoint not ready")
}
if hashJSON(sources) != m.SourceHash {
return m, nil, errors.New("source checkpoint changed")
}
if err := e.recheck(ctx, m, sources); err != nil {
return m, nil, err
}
return m, sources, nil
}
func (e *engine) generateScript(ctx context.Context) error {
m, sources, err := e.stage(ctx, "text")
if err != nil {
return err
}
payload := e.textPayload(sources, m.Config)
if _, err := modeledCost(payload, m.Config, m.Mode); err != nil {
return err
}
m.TextRequests = 1
m.Status = "script_requested"
if err := e.writeJSON("manifest.json", m); err != nil {
return err
}
data, ct, err := e.request(ctx, "https://api.openai.com/v1/responses", e.openAIKey, payload, 1<<20)
if err != nil {
return err
}
if ct != "application/json" {
return errors.New("text API returned an unexpected content type")
}
result, usage, err := parseResponse(data)
if err != nil {
return err
}
text, err := validateNarration(result, sources)
if err != nil {
return err
}
if len(strings.TrimSuffix(text, "\n")) > narrationByteLimit(m.Config) {
return errors.New("narration exceeds configured speech input limit; shorten optional highlights")
}
if err := e.writeFile("transcript.txt", []byte(text)); err != nil {
return err
}
if err := e.writeJSON("evidence.json", result); err != nil {
return err
}
m.Status = "script_validated"
m.ScriptHash = hashBytes([]byte(text))
m.Words = len(strings.Fields(text))
m.Characters = len([]rune(strings.TrimSuffix(text, "\n")))
m.TextUsage = usage
return e.writeJSON("manifest.json", m)
}
func (e *engine) mediaTools(ctx context.Context) error {
for _, tool := range []string{"ffprobe", "ffmpeg"} {
toolCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_, err := e.command(toolCtx, tool, "-version")
cancel()
if err != nil {
return fmt.Errorf("%s is required before paid audio generation", tool)
}
}
return nil
}
func (e *engine) generateSpeech(ctx context.Context) error {
m, sources, err := e.stage(ctx, "speech")
if err != nil {
return err
}
text, err := os.ReadFile(filepath.Join(e.output, "transcript.txt"))
if err != nil {
return errors.New("script checkpoint unavailable")
}
var result narration
if err := e.readJSON("evidence.json", &result); err != nil {
return err
}
validated, err := validateNarration(result, sources)
if err != nil || validated != string(text) || hashBytes(text) != m.ScriptHash {
return errors.New("script checkpoint changed or is invalid")
}
if err := e.mediaTools(ctx); err != nil {
return err
}
input := strings.TrimSuffix(string(text), "\n")
payload := map[string]any{"model": m.Config.TTSModel, "voice": m.Config.Voice, "input": input, "response_format": "mp3", "speed": m.Config.Speed}
if m.Config.Instructions != "" {
if len(input)+len(m.Config.Instructions) > miniInputBytes {
return errors.New("mini TTS conservative input-token limit exceeded")
}
payload["instructions"] = m.Config.Instructions
}
m.SpeechRequests = 1
m.Status = "speech_requested"
if err := e.writeJSON("manifest.json", m); err != nil {
return err
}
data, ct, err := e.request(ctx, "https://api.openai.com/v1/audio/speech", e.openAIKey, payload, 10<<20)
if err != nil {
return err
}
if (ct != "audio/mpeg" && ct != "audio/mp3" && ct != "application/octet-stream") || len(data) == 0 {
return errors.New("speech API did not return audio")
}
if err := e.writeFile("audio.tmp", data); err != nil {
return err
}
temporary := filepath.Join(e.output, "audio.tmp")
defer os.Remove(temporary)
mediaCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
probe, err := e.command(mediaCtx, "ffprobe", "-v", "error", "-f", "mp3", "-protocol_whitelist", "file,pipe", "-show_entries", "format=duration:stream=codec_name", "-of", "json", temporary)
if err != nil {
return errors.New("speech response cannot be inspected as MP3")
}
var info struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
Streams []struct {
Codec string `json:"codec_name"`
} `json:"streams"`
}
if json.Unmarshal(probe, &info) != nil || len(info.Streams) == 0 {
return errors.New("invalid MP3 metadata")
}
duration, err := strconv.ParseFloat(info.Format.Duration, 64)
if err != nil || math.IsNaN(duration) || math.IsInf(duration, 0) || duration <= 0 {
return errors.New("invalid MP3 duration")
}
for _, stream := range info.Streams {
if stream.Codec != "mp3" {
return errors.New("speech response is not MP3")
}
}
if _, err := e.command(mediaCtx, "ffmpeg", "-v", "error", "-xerror", "-f", "mp3", "-protocol_whitelist", "file,pipe", "-i", temporary, "-f", "null", "-"); err != nil {
return errors.New("MP3 failed complete decoding")
}
if err := os.Rename(temporary, filepath.Join(e.output, "release-podcast.mp3")); err != nil {
return errors.New("cannot install validated MP3")
}
m.Status = "audio_validated"
m.Duration = duration
m.DurationNeedsReview = duration < 105 || duration > 145
m.AudioHash = hashBytes(data)
return e.writeJSON("manifest.json", m)
}
func strictDecode(data []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return errors.New("invalid structured narration schema")
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return errors.New("unexpected data after narration")
}
return nil
}

View File

@ -0,0 +1,676 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func fixtures(t *testing.T) ([]releaseRecord, []source) {
t.Helper()
data, err := os.ReadFile("testdata/releases.json")
if err != nil {
t.Fatal(err)
}
var records []releaseRecord
if err := json.Unmarshal(data, &records); err != nil {
t.Fatal(err)
}
var sources []source
for _, r := range records {
s, err := normalizeRelease(r, false)
if err != nil {
t.Fatal(err)
}
sources = append(sources, s)
}
return records, sources
}
func exampleNarration(t *testing.T, sources []source) narration {
t.Helper()
texts := []struct {
index int
text, quote string
}{
{0, "Version 0.64.0 introduced experimental Jellyfin music support, which must be explicitly enabled.", "Enable it with `Jellyfin.Enabled = true`."},
{0, "Back up your database before upgrading because internal IDs change; clients may need to resync cached IDs.", "back up your database before upgrading"},
{0, "Plugin authors must migrate to the host HTTP service and review restrictions on private or loopback network addresses.", "Plugins must use the host HTTP service instead"},
{0, "Shares now belong to their creator, and admins cannot create them for another user.", "Shares are always owned by the user who creates them."},
{0, "Negative configuration durations are rejected at startup, and unknown options produce warnings.", "Negative values are rejected at startup."},
{0, "Security fixes protect library access and plugin networking, alongside improvements to artwork, sorting and playlist imports.", "This release fixes several reported vulnerabilities."},
{0, "Database restore also avoids wiping existing data when the backup file is missing.", "wiping the database when the backup file does not exist"},
{1, "Version 0.64.1 is a security release fixing five vulnerabilities; upgrade as soon as practical.", "This is a security release. It fixes five vulnerabilities"},
{1, "Jellyfin client compatibility improves, and Quick Connect makes signing in easier.", "Add Quick Connect sign-in."},
{1, "Local discovery is opt-in, and Docker users need host networking for discovery broadcasts.", "Docker users need host networking for the UDP broadcast to reach the container."},
{1, "Smart playlists can reference another playlist by path, while the interface follows your selected language for dates.", "Smart playlists can reference another playlist by path"},
{2, "Version 0.64.2 fixes scan failures and database lock contention on slow storage.", "Fix `database is locked` errors, UI freezes and failed scans on slow storage."},
{2, "It also fixes scans on 32-bit builds with invalid track metadata.", "32-bit builds (armv5/6/7, 386)"},
{2, "Security fixes sanitize download names and prevent an admin password from reaching logs.", "Stop writing the admin password to the log"},
}
r := narration{Cautions: []coverage{}}
for _, item := range texts {
if !strings.Contains(sources[item.index].Body, item.quote) {
t.Fatalf("fixture quote absent: %s", item.quote)
}
r.Sentences = append(r.Sentences, sentence{Text: item.text, SourceID: sources[item.index].SourceID, Excerpt: item.quote})
}
for _, c := range cautions(sources) {
index := 0
for i, s := range r.Sentences {
if s.SourceID == c.SourceID {
index = i
break
}
}
r.Cautions = append(r.Cautions, coverage{CautionID: c.ID, SentenceIndex: &index})
}
return r
}
func response(status int, ct string, data []byte) *http.Response {
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{ct}}, Body: io.NopCloser(bytes.NewReader(data))}
}
func testEngine(t *testing.T) (*engine, []releaseRecord, []source) {
t.Helper()
t.Setenv("GH_TOKEN", "")
t.Setenv("OPENAI_API_KEY", "")
e, err := newEngine(t.TempDir())
if err != nil {
t.Fatal(err)
}
e.openAIKey = "offline-test-key"
records, sources := fixtures(t)
e.command = func(_ context.Context, name string, args ...string) ([]byte, error) {
if name == "git" {
return []byte("test-commit"), nil
}
if len(args) == 1 && args[0] == "-version" {
return nil, nil
}
if name == "ffprobe" {
return []byte(`{"format":{"duration":"120.5"},"streams":[{"codec_name":"mp3"}]}`), nil
}
if name == "ffmpeg" {
return nil, nil
}
return nil, errors.New("unexpected command")
}
e.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "api.github.com" {
if req.URL.Path == "/repos/"+repository+"/releases" {
data, _ := json.Marshal(records)
return response(200, "application/json", data), nil
}
if strings.Contains(req.URL.Path, "/actions/artifacts") {
return response(200, "application/json", []byte(`{"artifacts":[]}`)), nil
}
for _, record := range records {
if req.URL.Path == "/repos/"+repository+"/releases/tags/"+record.Tag || req.URL.Path == "/repos/"+repository+"/releases/"+strconv.FormatInt(record.ID, 10) {
data, _ := json.Marshal(record)
return response(200, "application/json", data), nil
}
}
}
if req.URL.Host == "api.openai.com" {
if req.URL.Path == "/v1/responses" {
data, _ := json.Marshal(exampleNarration(t, sources))
body, _ := json.Marshal(map[string]any{"status": "completed", "usage": map[string]int{"input_tokens": 1000}, "output": []any{map[string]any{"type": "message", "content": []any{map[string]string{"type": "output_text", "text": string(data)}}}}})
return response(200, "application/json", body), nil
}
if req.URL.Path == "/v1/audio/speech" {
return response(200, "audio/mpeg", []byte("mock-mp3-data")), nil
}
}
t.Fatalf("unmocked network request: %s", req.URL.Host+req.URL.Path)
return nil, errors.New("unmocked request")
})
return e, records, sources
}
func audioOptions() options {
return options{tags: "v0.64.0,v0.64.1,v0.64.2", mode: "audio", textModel: "gpt-6-luna", ttsModel: "gpt-4o-mini-tts-2025-12-15", voice: "onyx", allowPaid: true}
}
func TestTagSelection(t *testing.T) {
for _, raw := range []string{"", "v0.64.0,", "v0.64.0,0.64.0", "v1.0.0,v2.0.0,v3.0.0,v4.0.0", "$(touch secret)", "../../foo", "v1.0.0\nmalicious", "v01.2.3", "v1.2.3١", "v99999999999.0.0"} {
t.Run(raw, func(t *testing.T) {
if _, err := parseTags(raw); err == nil {
t.Fatal("unsafe tags accepted")
}
})
}
tags, err := parseTags("0.64.2, v0.64.0,v0.64.1")
if err != nil || strings.Join(tags, ",") != "v0.64.0,v0.64.1,v0.64.2" {
t.Fatal(tags, err)
}
}
func TestReleaseEligibility(t *testing.T) {
records, _ := fixtures(t)
cases := map[string]func(*releaseRecord){"draft": func(r *releaseRecord) { r.Draft = true }, "prerelease": func(r *releaseRecord) { r.Prerelease = true }, "empty": func(r *releaseRecord) { r.Body = " " }, "unpublished": func(r *releaseRecord) { r.PublishedAt = "" }, "id": func(r *releaseRecord) { r.ID = 0 }, "oversized": func(r *releaseRecord) { r.Body = strings.Repeat("x", maxSourceBytes+1) }, "multiple tags": func(r *releaseRecord) { r.Tag = "v0.64.0,v0.64.1" }}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
r := records[0]
mutate(&r)
if _, err := normalizeRelease(r, false); err == nil {
t.Fatal("ineligible release accepted")
}
})
}
r := records[0]
r.Prerelease = true
r.Tag = "v0.64.0-rc.1"
if _, err := normalizeRelease(r, true); err != nil {
t.Fatal(err)
}
}
func TestInclusiveRangeSelection(t *testing.T) {
e, _, _ := testEngine(t)
sources, err := e.resolveLocal(t.Context(), options{from: "0.64.0", to: "v0.64.2"})
if err != nil || len(sources) != 3 || sources[0].Tag != "v0.64.0" || sources[2].Tag != "v0.64.2" {
t.Fatal(sources, err)
}
for _, o := range []options{{from: "v0.64.2", to: "v0.64.0"}, {from: "v0.63.0", to: "v0.64.2"}, {from: "v0.64.0"}, {tags: "v0.64.0", from: "v0.64.0", to: "v0.64.2"}, {from: "v0.64.0-rc.1", to: "v0.64.2"}} {
if _, err := e.resolveLocal(t.Context(), o); err == nil {
t.Fatal("invalid range accepted", o)
}
}
}
func TestRangeDiscardsDraftsAndFailsClosedAtLimit(t *testing.T) {
e, records, _ := testEngine(t)
draft := records[0]
draft.ID = 999
draft.Draft = true
draft.Tag = "v0.64.0-rc.1"
draft.Body = "private advisory never sent"
data, _ := json.Marshal(append(records, draft))
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return response(200, "application/json", data), nil })
sources, err := e.resolveLocal(t.Context(), options{from: "v0.64.0", to: "v0.64.2", includePrereleases: true})
if err != nil || len(sources) != 3 {
t.Fatal(sources, err)
}
page := make([]releaseRecord, 100)
for i := range page {
page[i] = releaseRecord{Draft: true}
}
data, _ = json.Marshal(page)
calls := 0
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return response(200, "application/json", data), nil
})
if _, err := e.resolveLocal(t.Context(), options{from: "v0.64.0", to: "v0.64.2"}); err == nil || calls != 10 {
t.Fatal("unbounded/incomplete range accepted", calls, err)
}
}
func TestConfigurationAndCostLimits(t *testing.T) {
e, _, sources := testEngine(t)
cfg, err := reviewedConfig("gpt-6-luna", "gpt-4o-mini-tts-2025-12-15", "cedar", "audio")
if err != nil {
t.Fatal(err)
}
payload := e.textPayload(sources, cfg)
cost, err := modeledCost(payload, cfg, "audio")
if err != nil || cost == nil || *cost > maxCostUSD {
t.Fatal(cost, err)
}
if payload["store"] != false || payload["tools"] != nil || payload["reasoning"] == nil {
t.Fatal("unsafe text configuration")
}
if _, err := modeledCost(strings.Repeat("x", maxPromptBytes+1), cfg, "audio"); err == nil {
t.Fatal("oversized prompt accepted")
}
hd, err := reviewedConfig("gpt-4.1-mini-2025-04-14", "tts-1-hd", "onyx", "audio")
if err != nil {
t.Fatal(err)
}
if _, err := modeledCost(strings.Repeat("x", 60000), hd, "audio"); err == nil {
t.Fatal("modeled dollar ceiling ignored")
}
for _, values := range [][3]string{{"", "", ""}, {"unreviewed", "tts-1", "onyx"}, {"gpt-6-luna", "unreviewed", "onyx"}, {"gpt-6-luna", "tts-1", "cedar"}, {"gpt-6-luna", "tts-1", "echo,fable"}} {
if _, err := reviewedConfig(values[0], values[1], values[2], "audio"); err == nil {
t.Fatal("invalid configuration accepted", values)
}
}
if _, err := reviewedConfig("", "", "", "validate"); err != nil {
t.Fatal(err)
}
legacy, err := reviewedConfig("gpt-6-luna", "tts-1", "onyx", "audio")
if err != nil || legacy.Instructions != "" {
t.Fatal(legacy, err)
}
}
func TestCLIDryRunAndPaidGuards(t *testing.T) {
t.Setenv("AUDIO_TEXT_MODEL", "gpt-6-luna")
o, err := parseOptions([]string{"--tags", "0.64.2", "--text-model", "gpt-4.1-mini-2025-04-14", "--mode", "audio", "--dry-run", "--output", "out"}, io.Discard)
if err != nil || o.mode != "validate" || o.textModel != "gpt-4.1-mini-2025-04-14" || o.output != "out" {
t.Fatal(o, err)
}
if _, err := parseOptions([]string{"--openai-key", "never-pass-a-key"}, io.Discard); err == nil {
t.Fatal("key argument accepted")
}
e, _, _ := testEngine(t)
paid := audioOptions()
paid.allowPaid = false
if err := e.runLocal(t.Context(), paid, io.Discard); err == nil {
t.Fatal("paid mode needs explicit flag")
}
paid.allowPaid = true
e.openAIKey = ""
if err := e.runLocal(t.Context(), paid, io.Discard); err == nil {
t.Fatal("paid mode needs local environment key")
}
}
func TestLocalValidationNeverContactsOpenAI(t *testing.T) {
e, _, _ := testEngine(t)
original := e.client.Transport
e.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "api.openai.com" {
t.Fatal("validation contacted OpenAI")
}
return original.RoundTrip(req)
})
if err := e.runLocal(t.Context(), options{from: "v0.64.0", to: "v0.64.2", mode: "validate"}, io.Discard); err != nil {
t.Fatal(err)
}
var m manifest
if err := e.readJSON("manifest.json", &m); err != nil || m.TextRequests != 0 || m.SpeechRequests != 0 || m.Origin != "local" {
t.Fatal(m, err)
}
}
func TestLocalPaidPipelineAndDuplicateProtection(t *testing.T) {
e, _, _ := testEngine(t)
if err := e.runLocal(t.Context(), audioOptions(), io.Discard); err != nil {
t.Fatal(err)
}
var m manifest
if err := e.readJSON("manifest.json", &m); err != nil {
t.Fatal(err)
}
if m.Status != "audio_validated" || m.TextRequests != 1 || m.SpeechRequests != 1 || m.AudioHash == "" || m.ScriptHash == "" || m.Duration != 120.5 {
t.Fatal(m)
}
for _, name := range []string{"release-podcast.mp3", "transcript.txt", "evidence.json", "sources.json", "manifest.json"} {
if _, err := os.Stat(filepath.Join(e.output, name)); err != nil {
t.Fatal(err)
}
}
if err := e.runLocal(t.Context(), audioOptions(), io.Discard); err == nil {
t.Fatal("duplicate attempt permitted")
}
o := audioOptions()
o.force = true
if err := e.runLocal(t.Context(), o, io.Discard); err != nil {
t.Fatal(err)
}
}
func TestLocalLedgerPersistsFailuresAndLocksConcurrentAttempts(t *testing.T) {
e, _, sources := testEngine(t)
cfg, _ := reviewedConfig("gpt-6-luna", "tts-1", "onyx", "audio")
m, err := e.prepare(t.Context(), sources, cfg, "audio", "local", false, false)
if err != nil {
t.Fatal(err)
}
release, err := e.reserveLocal(m, false)
if err != nil {
t.Fatal(err)
}
if _, err := e.reserveLocal(m, true); err == nil {
t.Fatal("force bypassed active lock")
}
release()
if _, err := e.reserveLocal(m, false); err == nil {
t.Fatal("reserved failed attempt retried")
}
release, err = e.reserveLocal(m, true)
if err != nil {
t.Fatal(err)
}
release()
}
func TestEvidenceAndConsequentialOmissions(t *testing.T) {
_, sources := fixtures(t)
if _, err := validateNarration(exampleNarration(t, sources), sources); err != nil {
t.Fatal(err)
}
for _, term := range []string{"Back up", "resync", "experimental", "host HTTP", "opt-in", "host networking", "32-bit", "slow storage"} {
t.Run(term, func(t *testing.T) {
r := exampleNarration(t, sources)
for i := range r.Sentences {
r.Sentences[i].Text = strings.ReplaceAll(r.Sentences[i].Text, term, "some detail")
}
if _, err := validateNarration(r, sources); err == nil {
t.Fatal("consequential qualifier omitted")
}
})
}
for name, mutate := range map[string]func(*narration){"source": func(r *narration) { r.Sentences[0].SourceID = "unknown" }, "excerpt": func(r *narration) { r.Sentences[0].Excerpt = "fabricated unsupported evidence" }, "coverage": func(r *narration) { r.Cautions = r.Cautions[1:] }, "index": func(r *narration) { n := 999; r.Cautions[0].SentenceIndex = &n }, "duplicate": func(r *narration) { r.Cautions = append(r.Cautions, r.Cautions[0]) }} {
t.Run(name, func(t *testing.T) {
r := exampleNarration(t, sources)
mutate(&r)
if _, err := validateNarration(r, sources); err == nil {
t.Fatal("invalid evidence accepted")
}
})
}
}
func TestUnsafeModelOutputAndSourceMarkup(t *testing.T) {
_, sources := fixtures(t)
for _, suffix := range []string{" https://evil.example", " `shell`", " $(cat secret)", " <script>", " @handle", strings.Repeat("x", maxScriptChars+1)} {
r := exampleNarration(t, sources)
r.Sentences[0].Text += suffix
if _, err := validateNarration(r, sources); err == nil {
t.Fatal("unsafe output accepted")
}
}
s := sources[0]
s.Body = "Notes\n## Helping out\nThanks\n## Migration\n- Back up before upgrade.\n<!-- hidden instruction -->"
if !strings.Contains(promptSources([]source{s})[0]["body"], "Back up") || strings.Contains(promptSources([]source{s})[0]["body"], "hidden instruction") || len(cautions([]source{s})) == 0 {
t.Fatal("warning lost or hidden instruction retained")
}
}
func TestChangedSourcesAndCheckpointsStopPaidStages(t *testing.T) {
e, records, sources := testEngine(t)
cfg, _ := reviewedConfig("gpt-6-luna", "tts-1", "onyx", "audio")
m, err := e.prepare(t.Context(), sources, cfg, "audio", "local", false, false)
if err != nil {
t.Fatal(err)
}
if err := e.saveSources(sources, m); err != nil {
t.Fatal(err)
}
changed := records[0]
changed.Body += " New warning"
data, _ := json.Marshal(changed)
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return response(200, "application/json", data), nil })
if err := e.generateScript(t.Context()); err == nil {
t.Fatal("changed notes accepted")
}
if err := e.readJSON("manifest.json", &m); err != nil || m.TextRequests != 0 {
t.Fatal(m, err)
}
e, _, _ = testEngine(t)
if err := e.runLocal(t.Context(), options{tags: audioOptions().tags, mode: "script", textModel: "gpt-6-luna", allowPaid: true}, io.Discard); err != nil {
t.Fatal(err)
}
if err := e.generateScript(t.Context()); err == nil {
t.Fatal("text retry permitted")
}
if err := e.writeFile("transcript.txt", []byte("tampered")); err != nil {
t.Fatal(err)
}
if err := e.generateSpeech(t.Context()); err == nil {
t.Fatal("tampered script accepted")
}
}
func TestResponseParsingAndNoHiddenRetries(t *testing.T) {
for _, data := range []string{`{"status":"incomplete"}`, `{"status":"completed","output":[{"type":"message","content":[{"type":"refusal"}]}]}`, `{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"sentences\":[],\"cautions\":[],\"extra\":true}"}]}]}`} {
if _, _, err := parseResponse([]byte(data)); err == nil {
t.Fatal("invalid/refused response accepted")
}
}
e, _, _ := testEngine(t)
for _, status := range []int{401, 429, 500} {
calls := 0
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return response(status, "application/json", []byte("secret-remote-body")), nil
})
_, _, err := e.request(t.Context(), "https://api.openai.com/v1/responses", "secret-key", map[string]string{"input": "public notes"}, 100)
if err == nil || calls != 1 || strings.Contains(err.Error(), "secret") {
t.Fatal(calls, err)
}
}
calls := 0
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return nil, errors.New("secret error details") })
if _, _, err := e.request(t.Context(), "https://api.openai.com/v1/responses", "key", struct{}{}, 100); err == nil || calls != 1 || strings.Contains(err.Error(), "secret") {
t.Fatal(calls, err)
}
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return response(200, "audio/mpeg", bytes.Repeat([]byte("x"), 101)), nil
})
if _, _, err := e.request(t.Context(), "https://api.openai.com/v1/audio/speech", "key", struct{}{}, 100); err == nil {
t.Fatal("response limit ignored")
}
}
func TestCredentialsNeverFollowRedirects(t *testing.T) {
called := false
e, err := newEngine(t.TempDir())
if err != nil {
t.Fatal(err)
}
e.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "evil.example" {
called = true
return response(200, "application/json", nil), nil
}
r := response(http.StatusFound, "application/json", nil)
r.Header.Set("Location", "https://evil.example/")
return r, nil
})
if _, _, err := e.request(t.Context(), "https://api.openai.com/v1/responses", "never-forward-me", nil, 100); err == nil || called {
t.Fatal("authenticated redirect followed", err)
}
}
func actionsEnvironment(t *testing.T, record releaseRecord, manual bool) {
t.Helper()
t.Setenv("GITHUB_ACTIONS", "true")
t.Setenv("GITHUB_REPOSITORY", repository)
t.Setenv("GITHUB_RUN_ID", "123")
t.Setenv("GITHUB_RUN_ATTEMPT", "1")
t.Setenv("AUDIO_ENABLED", "true")
t.Setenv("AUDIO_KEY_CONFIGURED", "true")
t.Setenv("AUDIO_TEXT_MODEL", "gpt-6-luna")
t.Setenv("AUDIO_TTS_MODEL", "tts-1")
t.Setenv("AUDIO_VOICE", "onyx")
dir := t.TempDir()
ev := map[string]any{"repository": map[string]string{"default_branch": "master"}, "action": "published", "release": record}
if manual {
t.Setenv("GITHUB_EVENT_NAME", "workflow_dispatch")
t.Setenv("GITHUB_REF", "refs/heads/master")
ev["inputs"] = map[string]string{"tags": "v0.64.0,v0.64.1,v0.64.2", "mode": "audio"}
} else {
t.Setenv("GITHUB_EVENT_NAME", "release")
t.Setenv("GITHUB_REF", "refs/tags/"+record.Tag)
}
data, _ := json.Marshal(ev)
eventFile := filepath.Join(dir, "event.json")
if err := os.WriteFile(eventFile, data, 0600); err != nil {
t.Fatal(err)
}
t.Setenv("GITHUB_EVENT_PATH", eventFile)
output := filepath.Join(dir, "outputs")
if err := os.WriteFile(output, nil, 0600); err != nil {
t.Fatal(err)
}
t.Setenv("GITHUB_OUTPUT", output)
}
func TestActionsEventIdentityAndTrustGuards(t *testing.T) {
e, records, _ := testEngine(t)
actionsEnvironment(t, records[2], false)
if err := e.runGitHub(t.Context(), "prepare"); err != nil {
t.Fatal(err)
}
var sources []source
if err := e.readJSON("sources.json", &sources); err != nil || len(sources) != 1 || sources[0].ID != records[2].ID {
t.Fatal(sources, err)
}
for name, value := range map[string]string{"GITHUB_REPOSITORY": "attacker/navidrome", "GITHUB_EVENT_NAME": "pull_request", "GITHUB_ACTIONS": "false"} {
previous := os.Getenv(name)
t.Setenv(name, value)
if _, err := githubEvent(); err == nil {
t.Fatal("untrusted context accepted", name)
}
t.Setenv(name, previous)
}
actionsEnvironment(t, records[0], true)
t.Setenv("GITHUB_REF", "refs/heads/untrusted")
if err := e.runGitHub(t.Context(), "prepare"); err == nil {
t.Fatal("non-default branch accepted")
}
if err := run(t.Context(), []string{"--tags", "v0.64.0", "--mode", "audio", "--allow-paid"}, io.Discard, io.Discard); err == nil {
t.Fatal("Actions used local entrypoint")
}
}
func TestActionsEnablementAndConfigChanges(t *testing.T) {
e, records, _ := testEngine(t)
actionsEnvironment(t, records[0], true)
t.Setenv("AUDIO_KEY_CONFIGURED", "false")
if err := e.runGitHub(t.Context(), "prepare"); err == nil {
t.Fatal("missing secret accepted")
}
t.Setenv("AUDIO_KEY_CONFIGURED", "true")
t.Setenv("AUDIO_ENABLED", "false")
if err := e.runGitHub(t.Context(), "prepare"); err == nil {
t.Fatal("disabled generation accepted")
}
t.Setenv("AUDIO_ENABLED", "true")
if err := e.runGitHub(t.Context(), "prepare"); err != nil {
t.Fatal(err)
}
t.Setenv("AUDIO_VOICE", "nova")
if err := e.runGitHub(t.Context(), "script"); err == nil {
t.Fatal("configuration changed between stages")
}
t.Setenv("AUDIO_VOICE", "onyx")
t.Setenv("GITHUB_RUN_ATTEMPT", "2")
if err := e.runGitHub(t.Context(), "script"); err == nil {
t.Fatal("checkpoint from another attempt accepted")
}
t.Setenv("GITHUB_RUN_ATTEMPT", "1")
var m manifest
if err := e.readJSON("manifest.json", &m); err != nil {
t.Fatal(err)
}
m.Origin = "local"
if err := e.writeJSON("manifest.json", m); err != nil {
t.Fatal(err)
}
if err := e.runGitHub(t.Context(), "script"); err == nil {
t.Fatal("local checkpoint accepted by Actions")
}
}
func TestArtifactDuplicatesExpiryAndLookupBounds(t *testing.T) {
e, _, _ := testEngine(t)
for _, expired := range []bool{false, true} {
body, _ := json.Marshal(map[string]any{"artifacts": []any{map[string]any{"name": "key-123-1", "expired": expired}}})
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return response(200, "application/json", body), nil })
duplicate, err := e.duplicateArtifact(t.Context(), "key")
if err != nil || duplicate == expired {
t.Fatal(duplicate, err)
}
}
artifacts := make([]map[string]any, 100)
for i := range artifacts {
artifacts[i] = map[string]any{"name": "other", "expired": false}
}
body, _ := json.Marshal(map[string]any{"artifacts": artifacts})
calls := 0
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return response(200, "application/json", body), nil
})
if _, err := e.duplicateArtifact(t.Context(), "key"); err == nil || calls != 100 {
t.Fatal("ledger lookup not bounded", calls, err)
}
}
func TestInvalidMP3NeverBecomesOutput(t *testing.T) {
for _, metadata := range []string{`{"format":{"duration":"nan"},"streams":[{"codec_name":"mp3"}]}`, `{"format":{"duration":"0"},"streams":[{"codec_name":"mp3"}]}`, `{"format":{"duration":"120"},"streams":[{"codec_name":"aac"}]}`} {
e, _, _ := testEngine(t)
original := e.command
e.command = func(ctx context.Context, name string, args ...string) ([]byte, error) {
if name == "ffprobe" && len(args) > 1 {
return []byte(metadata), nil
}
return original(ctx, name, args...)
}
if err := e.runLocal(t.Context(), audioOptions(), io.Discard); err == nil {
t.Fatal("invalid MP3 accepted")
}
if _, err := os.Stat(filepath.Join(e.output, "release-podcast.mp3")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("invalid output exists", err)
}
if _, err := os.Stat(filepath.Join(e.output, "audio.tmp")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("temporary output retained", err)
}
}
}
func TestMissingMediaToolsStopBeforeOpenAI(t *testing.T) {
e, _, _ := testEngine(t)
e.command = func(context.Context, string, ...string) ([]byte, error) { return nil, errors.New("tool missing") }
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("network should not start before media preflight")
return nil, errors.New("unexpected")
})
if err := e.runLocal(t.Context(), audioOptions(), io.Discard); err == nil {
t.Fatal("missing media tools accepted")
}
}
func TestRealMP3DecodeWithMockedOpenAI(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg unavailable")
}
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe unavailable")
}
e, _, _ := testEngine(t)
file := filepath.Join(t.TempDir(), "tone.mp3")
if _, err := exec.CommandContext(t.Context(), "ffmpeg", "-v", "error", "-f", "lavfi", "-i", "sine=frequency=440", "-t", "0.2", "-c:a", "libmp3lame", file).Output(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
original := e.client.Transport
e.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "api.openai.com" && req.URL.Path == "/v1/audio/speech" {
return response(200, "audio/mpeg", data), nil
}
return original.RoundTrip(req)
})
e.command = func(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).Output()
}
if err := e.runLocal(t.Context(), audioOptions(), io.Discard); err != nil {
t.Fatal(err)
}
var m manifest
if err := e.readJSON("manifest.json", &m); err != nil || !m.DurationNeedsReview || m.Duration <= 0 {
t.Fatal(m, err)
}
}

292
release/podcast/source.go Normal file
View File

@ -0,0 +1,292 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
)
const maxSourceBytes = 65536
var tagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*)?$`)
type releaseRecord struct {
ID int64 `json:"id"`
Tag string `json:"tag_name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt string `json:"published_at"`
UpdatedAt string `json:"updated_at"`
}
type source struct {
ID int64 `json:"id"`
SourceID string `json:"source_id"`
Tag string `json:"tag"`
URL string `json:"url"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
UpdatedAt string `json:"updated_at"`
Prerelease bool `json:"prerelease"`
}
func version(tag string) ([3]uint64, error) {
var v [3]uint64
matches := tagPattern.FindStringSubmatch(tag)
if len(tag) > 80 || matches == nil {
return v, errors.New("versions must look like v0.64.2")
}
for i := range v {
n, err := strconv.ParseUint(matches[i+1], 10, 32)
if err != nil {
return v, errors.New("version component exceeds limit")
}
v[i] = n
}
return v, nil
}
func normalizeTag(tag string) (string, error) {
tag = strings.TrimSpace(tag)
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
_, err := version(tag)
return tag, err
}
func compareVersion(a, b string) int {
va, _ := version(a)
vb, _ := version(b)
for i := range va {
if va[i] < vb[i] {
return -1
}
if va[i] > vb[i] {
return 1
}
}
return strings.Compare(a, b)
}
func parseTags(raw string) ([]string, error) {
parts := strings.Split(raw, ",")
if len(parts) > 3 {
return nil, errors.New("select at most three distinct published releases")
}
seen := map[string]bool{}
for i, item := range parts {
tag, err := normalizeTag(item)
if err != nil {
return nil, err
}
if seen[tag] {
return nil, errors.New("duplicate release versions")
}
seen[tag] = true
parts[i] = tag
}
sort.Slice(parts, func(i, j int) bool { return compareVersion(parts[i], parts[j]) < 0 })
return parts, nil
}
func normalizeRelease(r releaseRecord, allowPrerelease bool) (source, error) {
if _, err := version(r.Tag); err != nil {
return source{}, err
}
if r.ID <= 0 || r.Draft || r.PublishedAt == "" {
return source{}, errors.New("only published releases with valid IDs are eligible")
}
if r.Prerelease && !allowPrerelease {
return source{}, errors.New("published prereleases require explicit opt-in")
}
if strings.TrimSpace(r.Body) == "" {
return source{}, errors.New("release notes are empty; add notes before generating")
}
if len(r.Body) > maxSourceBytes {
return source{}, errors.New("release notes exceed the byte limit; never truncate warnings")
}
return source{ID: r.ID, SourceID: strconv.FormatInt(r.ID, 10), Tag: r.Tag,
URL: "https://github.com/" + repository + "/releases/tag/" + url.PathEscape(r.Tag),
Body: strings.ReplaceAll(r.Body, "\r\n", "\n"), PublishedAt: r.PublishedAt, UpdatedAt: r.UpdatedAt, Prerelease: r.Prerelease}, nil
}
func (e *engine) request(ctx context.Context, endpoint, token string, payload any, limit int64) ([]byte, string, error) {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, "", errors.New("cannot encode request")
}
body = bytes.NewReader(data)
}
method := http.MethodGet
if payload != nil {
method = http.MethodPost
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return nil, "", errors.New("invalid request")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
response, err := e.client.Do(req)
if err != nil {
return nil, "", errors.New("network request failed; requests are never retried")
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, "", fmt.Errorf("HTTP %d; requests are never retried", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
if err != nil {
return nil, "", errors.New("cannot read response; requests are never retried")
}
if int64(len(data)) > limit {
return nil, "", errors.New("response exceeds byte limit")
}
contentType := strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0])
return data, contentType, nil
}
func (e *engine) github(ctx context.Context, path string, target any) error {
data, _, err := e.request(ctx, "https://api.github.com/repos/"+repository+path, e.githubToken, nil, 8<<20)
if err != nil {
return err
}
if err := json.Unmarshal(data, target); err != nil {
return errors.New("invalid GitHub response")
}
return nil
}
func (e *engine) fetchTag(ctx context.Context, tag string, allow bool) (source, error) {
var record releaseRecord
if err := e.github(ctx, "/releases/tags/"+url.PathEscape(tag), &record); err != nil {
return source{}, err
}
if record.Tag != tag {
return source{}, errors.New("release tag identity changed")
}
return normalizeRelease(record, allow)
}
func (e *engine) resolveLocal(ctx context.Context, o options) ([]source, error) {
if o.tags != "" {
if o.from != "" || o.to != "" {
return nil, errors.New("use either --tags or --from/--to")
}
tags, err := parseTags(o.tags)
if err != nil {
return nil, err
}
var sources []source
for _, tag := range tags {
s, err := e.fetchTag(ctx, tag, o.includePrereleases)
if err != nil {
return nil, err
}
sources = append(sources, s)
}
return sources, nil
}
return e.resolveRange(ctx, o)
}
func (e *engine) resolveRange(ctx context.Context, o options) ([]source, error) {
if o.from == "" || o.to == "" {
return nil, errors.New("select --tags or both --from and --to")
}
from, err := normalizeTag(o.from)
if err != nil {
return nil, err
}
to, err := normalizeTag(o.to)
if err != nil {
return nil, err
}
if strings.Contains(from, "-") || strings.Contains(to, "-") || compareVersion(from, to) > 0 {
return nil, errors.New("range endpoints must be ordered stable versions; use --tags for prereleases")
}
var selected []source
complete := false
// Never assume API publication order is version order. Scan a bounded list;
// discard unpublished/draft bodies and never send them to OpenAI or artifacts.
for page := 1; page <= 10; page++ {
var records []releaseRecord
if err := e.github(ctx, fmt.Sprintf("/releases?per_page=100&page=%d", page), &records); err != nil {
return nil, err
}
for _, r := range records {
if r.Draft || r.PublishedAt == "" {
continue
}
if _, err := version(r.Tag); err != nil {
continue
}
if r.Prerelease && !o.includePrereleases {
continue
}
if compareVersion(r.Tag, from) < 0 || compareVersion(r.Tag, to) > 0 {
continue
}
s, err := normalizeRelease(r, o.includePrereleases)
if err != nil {
return nil, err
}
selected = append(selected, s)
if len(selected) > 3 {
return nil, errors.New("range selects more than three releases; narrow it or use --tags")
}
}
if len(records) < 100 {
complete = true
break
}
}
if !complete {
return nil, errors.New("release listing exceeds 1000-record limit; use --tags")
}
seenFrom, seenTo := false, false
for _, s := range selected {
seenFrom = seenFrom || s.Tag == from
seenTo = seenTo || s.Tag == to
}
if !seenFrom || !seenTo {
return nil, errors.New("range endpoints must both be published releases")
}
sort.Slice(selected, func(i, j int) bool { return compareVersion(selected[i].Tag, selected[j].Tag) < 0 })
return selected, nil
}
func (e *engine) recheck(ctx context.Context, m manifest, sources []source) error {
var current []source
for _, s := range sources {
var r releaseRecord
if err := e.github(ctx, fmt.Sprintf("/releases/%d", s.ID), &r); err != nil {
return err
}
n, err := normalizeRelease(r, m.IncludePrereleases)
if err != nil {
return err
}
current = append(current, n)
}
if hashJSON(current) != m.SourceHash {
return errors.New("published notes changed or were withdrawn; review a fresh attempt")
}
return nil
}

View File

@ -0,0 +1,219 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"unicode/utf8"
)
type sentence struct {
Text string `json:"text"`
SourceID string `json:"source_id"`
Excerpt string `json:"excerpt"`
}
type coverage struct {
CautionID string `json:"caution_id"`
SentenceIndex *int `json:"sentence_index"`
}
type narration struct {
Sentences []sentence `json:"sentences"`
Cautions []coverage `json:"cautions"`
}
type caution struct {
ID string `json:"caution_id"`
SourceID string `json:"source_id"`
Excerpt string `json:"excerpt"`
}
var htmlComments = regexp.MustCompile(`(?s)<!--.*?-->`)
var migrationHeader = regexp.MustCompile(`(?i)breaking|migration`)
var securityWord = regexp.MustCompile(`(?i)security`)
var qualifierWord = regexp.MustCompile(`(?i)back.?up|re-?sync|experimental|opt-in|disabled|host networking`)
var unsafeNarration = regexp.MustCompile("(?i)https?://|www\\.|[@`<>{}\\[\\]#]|\\$\\(|[\\x00-\\x08\\x0b-\\x1f]")
func promptSources(sources []source) []map[string]string {
result := make([]map[string]string, 0, len(sources))
for _, s := range sources {
result = append(result, map[string]string{"source_id": s.SourceID, "tag": s.Tag, "body": htmlComments.ReplaceAllString(s.Body, "")})
}
return result
}
func cautions(sources []source) []caution {
result := []caution{}
for _, s := range sources {
migration, securityAdded := false, false
for _, line := range strings.Split(htmlComments.ReplaceAllString(s.Body, ""), "\n") {
if strings.HasPrefix(line, "## ") {
migration = migrationHeader.MatchString(line)
}
mandatory := migration && strings.HasPrefix(line, "- ")
if securityWord.MatchString(line) && !securityAdded {
mandatory = true
securityAdded = true
}
if mandatory || qualifierWord.MatchString(line) {
result = append(result, caution{ID: fmt.Sprintf("c%d", len(result)), SourceID: s.SourceID, Excerpt: line})
}
}
}
return result
}
func narrationSchema() map[string]any {
stringType := map[string]string{"type": "string"}
return map[string]any{"type": "object", "additionalProperties": false, "required": []string{"sentences", "cautions"}, "properties": map[string]any{
"sentences": map[string]any{"type": "array", "items": map[string]any{"type": "object", "additionalProperties": false, "required": []string{"text", "source_id", "excerpt"}, "properties": map[string]any{"text": stringType, "source_id": stringType, "excerpt": stringType}}},
"cautions": map[string]any{"type": "array", "items": map[string]any{"type": "object", "additionalProperties": false, "required": []string{"caution_id", "sentence_index"}, "properties": map[string]any{"caution_id": stringType, "sentence_index": map[string]string{"type": "integer"}}}},
}}
}
func parseResponse(data []byte) (narration, json.RawMessage, error) {
var response struct {
Status string `json:"status"`
Usage json.RawMessage `json:"usage"`
Output []struct {
Type string `json:"type"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
} `json:"output"`
}
var result narration
if json.Unmarshal(data, &response) != nil || response.Status != "completed" {
return result, nil, errors.New("text response invalid, incomplete, or refused")
}
var texts []string
for _, output := range response.Output {
if output.Type != "message" {
continue
}
for _, part := range output.Content {
if part.Type == "refusal" {
return result, nil, errors.New("text response refused")
}
if part.Type == "output_text" {
texts = append(texts, part.Text)
}
}
}
if len(texts) != 1 {
return result, nil, errors.New("expected one structured narration")
}
if err := strictDecode([]byte(texts[0]), &result); err != nil {
return result, nil, err
}
return result, response.Usage, nil
}
func validateNarration(result narration, sources []source) (string, error) {
if result.Cautions == nil {
return "", errors.New("narration requires an explicit caution coverage array")
}
if len(result.Sentences) < 1 || len(result.Sentences) > 35 {
return "", errors.New("invalid narration sentence count")
}
byID := map[string]source{}
for _, s := range sources {
byID[s.SourceID] = s
}
texts := []string{}
for _, s := range result.Sentences {
original, ok := byID[s.SourceID]
if !ok || strings.TrimSpace(s.Text) == "" || len(s.Excerpt) < 12 || !strings.Contains(htmlComments.ReplaceAllString(original.Body, ""), s.Excerpt) {
return "", errors.New("sentence evidence is absent from its published source")
}
texts = append(texts, strings.TrimSpace(s.Text))
}
required := map[string]caution{}
for _, c := range cautions(sources) {
required[c.ID] = c
}
covered := map[string]bool{}
for _, c := range result.Cautions {
r, ok := required[c.CautionID]
if !ok || covered[c.CautionID] || c.SentenceIndex == nil || *c.SentenceIndex < 0 || *c.SentenceIndex >= len(result.Sentences) {
return "", errors.New("invalid or duplicate caution reference")
}
if result.Sentences[*c.SentenceIndex].SourceID != r.SourceID {
return "", errors.New("caution mapped to the wrong release")
}
covered[c.CautionID] = true
}
if len(covered) != len(required) {
return "", errors.New("missing required migration/security/qualifier coverage")
}
text := intro + "\n\n" + strings.Join(texts, " ") + "\n\n" + closing
if len(strings.Fields(text)) < 100 || len(strings.Fields(text)) > 280 || utf8.RuneCountInString(text) > maxScriptChars {
return "", errors.New("narration must be 100-280 words and at most 2500 characters")
}
if unsafeNarration.MatchString(text) {
return "", errors.New("narration contains markup, URL, handle, or executable content")
}
ascii := 0
for _, r := range text {
if r < 128 {
ascii++
}
}
if float64(ascii)/float64(utf8.RuneCountInString(text)) < 0.95 {
return "", errors.New("narration must be English plain text")
}
for _, s := range sources {
if !strings.Contains(text, strings.TrimPrefix(s.Tag, "v")) {
return "", errors.New("narration must identify every selected release version")
}
}
if err := validateQualifiers(result.Sentences, sources); err != nil {
return "", err
}
return text + "\n", nil
}
type qualifierRule struct {
trigger string
terms []string
}
var qualifierRules = []qualifierRule{
{`back up your database before upgrading`, []string{`back.?up`, `database`, `before.{0,40}upgrad`}},
{`may need to re-sync`, []string{`re.?sync`}},
{`experimental Jellyfin`, []string{`experimental`, `enabl|opt.in|default.off|disabled by default`}},
{`Plugin authors.*?Extism.*?disabled`, []string{`plugin`, `host.{0,20}HTTP|host.{0,20}network`, `private|loopback|LAN`}},
{`security release.*?Upgrade`, []string{`security`, `upgrad`}},
{`opt-in LAN auto-discovery`, []string{`opt.in`, `Docker`, `host networking`}},
{`slow storage`, []string{`slow.{0,20}storage`, `scan|lock`}},
{`32-bit builds`, []string{`32.bit`, `scan`}},
}
func validateQualifiers(sentences []sentence, sources []source) error {
// These are lexical checks, not proof of semantic entailment. Human review
// remains necessary even when all evidence and safety checks pass.
for _, s := range sources {
body := strings.NewReplacer("*", "", "`", "").Replace(s.Body)
for _, rule := range qualifierRules {
if !regexp.MustCompile("(?is)" + rule.trigger).MatchString(body) {
continue
}
matched := false
for _, sentence := range sentences {
if sentence.SourceID != s.SourceID {
continue
}
all := true
for _, term := range rule.terms {
all = all && regexp.MustCompile("(?i)"+term).MatchString(sentence.Text)
}
matched = matched || all
}
if !matched {
return errors.New("narration omits a consequential source qualifier or action")
}
}
}
return nil
}