mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(release): replace audio helper with Go podcast CLI
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
e3fde1fd74
commit
82eea674a5
@ -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
|
||||
@ -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
|
||||
1
release/audio/.gitignore
vendored
1
release/audio/.gitignore
vendored
@ -1 +0,0 @@
|
||||
__pycache__/
|
||||
@ -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)
|
||||
@ -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())
|
||||
@ -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
232
release/podcast/README.md
Normal 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
176
release/podcast/actions.go
Normal 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
182
release/podcast/main.go
Normal 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
471
release/podcast/pipeline.go
Normal 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
|
||||
}
|
||||
676
release/podcast/podcast_test.go
Normal file
676
release/podcast/podcast_test.go
Normal 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
292
release/podcast/source.go
Normal 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
|
||||
}
|
||||
219
release/podcast/validation.go
Normal file
219
release/podcast/validation.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user