diff --git a/.github/workflows/release-podcast.yml b/.github/workflows/release-podcast.yml index 035799240..d25597e34 100644 --- a/.github/workflows/release-podcast.yml +++ b/.github/workflows/release-podcast.yml @@ -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 diff --git a/release/podcast/README.md b/release/podcast/README.md index 7f9a1174a..f651977a6 100644 --- a/release/podcast/README.md +++ b/release/podcast/README.md @@ -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 diff --git a/release/podcast/actions.go b/release/podcast/actions.go index 0c6d132d7..66f890663 100644 --- a/release/podcast/actions.go +++ b/release/podcast/actions.go @@ -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) diff --git a/release/podcast/main.go b/release/podcast/main.go index 00b078023..942e88d73 100644 --- a/release/podcast/main.go +++ b/release/podcast/main.go @@ -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 diff --git a/release/podcast/podcast_test.go b/release/podcast/podcast_test.go index f9f2d6b46..dfe05672e 100644 --- a/release/podcast/podcast_test.go +++ b/release/podcast/podcast_test.go @@ -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") } } diff --git a/release/podcast/source.go b/release/podcast/source.go index 542915a30..c55aec682 100644 --- a/release/podcast/source.go +++ b/release/podcast/source.go @@ -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 {