refactor(release): select podcast releases with from and to

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-10-01 19:36:16 -04:00
commit ec6f3e1914
6 changed files with 80 additions and 70 deletions

View file

@ -5,11 +5,16 @@ on:
types: [published]
workflow_dispatch:
inputs:
tags:
description: Published release tags (at most three, comma separated)
default: v0.64.0,v0.64.1,v0.64.2
from:
description: Published version, or inclusive first version of a range
default: v0.64.0
required: true
type: string
to:
description: Optional inclusive last version (at most three releases)
default: v0.64.2
required: false
type: string
mode:
description: Validate is free; script and audio use OpenAI
default: validate

View file

@ -19,18 +19,20 @@ 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:
GitHub context. To select one published release, use `--from` alone:
```sh
go run ./release/podcast --tags v0.64.0,v0.64.1,v0.64.2 \
go run ./release/podcast --from v0.64.2 \
--mode validate --output /tmp/navidrome-podcast-validation
```
Select at most three distinct releases. Versions may omit the `v` prefix.
Select one release or an inclusive range of at most three releases. Versions
may omit the `v` prefix. There is no comma-separated tag-list option.
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.
single-release selection with `--from` alone. Published prereleases require
`--include-prereleases`; select a prerelease endpoint with `--from` alone.
Drafts are discarded.
Prereleases use SemVer precedence, including numeric identifiers: `rc.2`
precedes `rc.10`, and both precede the corresponding stable release. Thus a
stable lower range bound excludes its own RCs; a stable upper bound includes
@ -105,7 +107,8 @@ rename is required:
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
`validate`, `from=v0.64.0` and `to=v0.64.2`. Clear `to` to select only `from`.
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

View file

@ -16,7 +16,8 @@ type event struct {
DefaultBranch string `json:"default_branch"`
} `json:"repository"`
Inputs struct {
Tags string `json:"tags"`
From string `json:"from"`
To string `json:"to"`
Mode string `json:"mode"`
IncludePrereleases string `json:"include_prereleases"`
Force string `json:"force_regenerate"`
@ -131,7 +132,7 @@ func (e *engine) prepareGitHub(ctx context.Context, ev event) error {
}
var sources []source
if manual {
sources, err = e.resolveLocal(ctx, options{tags: ev.Inputs.Tags, includePrereleases: allow})
sources, err = e.resolveLocal(ctx, options{from: ev.Inputs.From, to: ev.Inputs.To, includePrereleases: allow})
} else {
var record releaseRecord
err = e.github(ctx, fmt.Sprintf("/releases/%d", ev.Release.ID), &record)

View file

@ -22,17 +22,16 @@ 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
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.from, "from", "", "Published version to select, or inclusive first version of a range (v prefix optional)")
f.StringVar(&o.to, "to", "", "Optional inclusive last published version of 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")
@ -55,7 +54,7 @@ func parseOptions(args []string, stderr io.Writer) (options, error) {
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) {
if o.githubStage != "" && (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

View file

@ -146,20 +146,51 @@ func testEngine(t *testing.T) (*engine, []releaseRecord, []source) {
}
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}
return options{from: "v0.64.0", to: "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) {
func TestVersionSelection(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 {
if _, err := normalizeTag(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)
tag, err := normalizeTag("0.64.2")
if err != nil || tag != "v0.64.2" {
t.Fatal(tag, err)
}
}
func TestSingleReleaseSelection(t *testing.T) {
e, records, _ := testEngine(t)
original := e.client.Transport
e.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Path != "/repos/"+repository+"/releases/tags/v0.64.2" {
t.Fatal("single release must use its exact tag endpoint", req.URL)
}
return original.RoundTrip(req)
})
for _, from := range []string{"0.64.2", "v0.64.2"} {
sources, err := e.resolveLocal(t.Context(), options{from: from})
if err != nil || len(sources) != 1 || sources[0].Tag != "v0.64.2" {
t.Fatal(sources, err)
}
}
if _, err := parseOptions([]string{"--tags", "v0.64.0,v0.64.1"}, io.Discard); err == nil {
t.Fatal("removed --tags flag remains accepted")
}
r := records[0]
r.Tag += "-rc.1"
r.Prerelease = true
data, _ := json.Marshal(r)
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { return response(200, "application/json", data), nil })
if _, err := e.resolveLocal(t.Context(), options{from: r.Tag}); err == nil {
t.Fatal("single prerelease accepted without opt-in")
}
if sources, err := e.resolveLocal(t.Context(), options{from: r.Tag, includePrereleases: true}); err != nil || len(sources) != 1 {
t.Fatal(sources, err)
}
}
@ -189,7 +220,7 @@ func TestInclusiveRangeSelection(t *testing.T) {
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"}} {
for _, o := range []options{{from: "v0.64.2", to: "v0.64.0"}, {from: "v0.63.0", to: "v0.64.2"}, {}, {to: "v0.64.2"}, {from: "v0.64.0,v0.64.1"}, {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)
}
@ -219,10 +250,6 @@ func TestSemVerPrereleasePrecedence(t *testing.T) {
t.Fatal("invalid SemVer prerelease accepted", tag)
}
}
tags, err := parseTags("v1.0.0,v1.0.0-rc.10,v1.0.0-rc.2")
if err != nil || strings.Join(tags, ",") != "v1.0.0-rc.2,v1.0.0-rc.10,v1.0.0" {
t.Fatal(tags, err)
}
}
func TestRangePrereleaseBoundaries(t *testing.T) {
@ -315,7 +342,7 @@ func TestConfigurationAndCostLimits(t *testing.T) {
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)
o, err := parseOptions([]string{"--from", "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)
}
@ -494,7 +521,7 @@ func TestChangedSourcesAndCheckpointsStopPaidStages(t *testing.T) {
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 {
if err := e.runLocal(t.Context(), options{from: audioOptions().from, to: audioOptions().to, mode: "script", textModel: "gpt-6-luna", allowPaid: true}, io.Discard); err != nil {
t.Fatal(err)
}
if err := e.generateScript(t.Context()); err == nil {
@ -575,7 +602,7 @@ func actionsEnvironment(t *testing.T, record releaseRecord, manual bool) {
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"}
ev["inputs"] = map[string]string{"from": "v0.64.0", "to": "v0.64.2", "mode": "audio"}
} else {
t.Setenv("GITHUB_EVENT_NAME", "release")
t.Setenv("GITHUB_REF", "refs/tags/"+record.Tag)
@ -616,7 +643,7 @@ func TestActionsEventIdentityAndTrustGuards(t *testing.T) {
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 {
if err := run(t.Context(), []string{"--from", "v0.64.0", "--mode", "audio", "--allow-paid"}, io.Discard, io.Discard); err == nil {
t.Fatal("Actions used local entrypoint")
}
}

View file

@ -125,27 +125,6 @@ func comparePrerelease(a, b string) int {
return len(ai) - len(bi)
}
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
@ -231,30 +210,26 @@ func (e *engine) fetchTag(ctx context.Context, tag string, allow bool) (source,
}
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 o.from == "" {
return nil, errors.New("--from is required; add --to for an inclusive range")
}
if o.to == "" {
tag, err := normalizeTag(o.from)
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)
s, err := e.fetchTag(ctx, tag, o.includePrereleases)
if err != nil {
return nil, err
}
return sources, nil
return []source{s}, 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")
return nil, errors.New("range requires both --from and --to")
}
from, err := normalizeTag(o.from)
if err != nil {
@ -265,7 +240,7 @@ func (e *engine) resolveRange(ctx context.Context, o options) ([]source, error)
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")
return nil, errors.New("range endpoints must be ordered stable versions; select a prerelease with --from alone")
}
var selected []source
complete := false
@ -295,7 +270,7 @@ func (e *engine) resolveRange(ctx context.Context, o options) ([]source, error)
}
selected = append(selected, s)
if len(selected) > 3 {
return nil, errors.New("range selects more than three releases; narrow it or use --tags")
return nil, errors.New("range selects more than three releases; narrow the range")
}
}
if len(records) < 100 {
@ -304,7 +279,7 @@ func (e *engine) resolveRange(ctx context.Context, o options) ([]source, error)
}
}
if !complete {
return nil, errors.New("release listing exceeds 1000-record limit; use --tags")
return nil, errors.New("release listing exceeds 1000-record limit; select a single release with --from")
}
seenFrom, seenTo := false, false
for _, s := range selected {