mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
refactor(release): select podcast releases with from and to
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
a607858873
commit
ec6f3e1914
6 changed files with 80 additions and 70 deletions
11
.github/workflows/release-podcast.yml
vendored
11
.github/workflows/release-podcast.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue