mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
fix(release): correct podcast reservations and prerelease validation
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
d315c2fe0b
commit
a607858873
6 changed files with 223 additions and 16 deletions
2
.github/workflows/release-podcast.yml
vendored
2
.github/workflows/release-podcast.yml
vendored
|
|
@ -75,6 +75,8 @@ jobs:
|
|||
if: steps.prepare.outputs.generate == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
# Exact deterministic name supports API filtering across runs.
|
||||
# Keep immutable within a run; force requires a fresh dispatch.
|
||||
name: ${{ steps.prepare.outputs.reservation }}
|
||||
path: release-podcast/manifest.json
|
||||
if-no-files-found: error
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ 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.
|
||||
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
|
||||
its RCs only with `--include-prereleases`.
|
||||
|
||||
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
|
||||
|
|
@ -180,12 +184,17 @@ 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
|
||||
Jobs serialize separately from the build pipeline. Before paid requests, the
|
||||
GitHub API's exact `name` filter looks up the deterministic reservation name;
|
||||
unrelated repository artifacts do not enter pagination. A bounded lookup of up
|
||||
to 10,000 matching reservations 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.
|
||||
Use a fresh manual dispatch for forced generation. Reservation names repeat
|
||||
across runs but are immutable within a run: a rerun of an already-reserved
|
||||
forced attempt fails at upload before any paid request, preserving the ledger.
|
||||
|
||||
Reservations last 90 days, review outputs/script checkpoints 30 days, limited
|
||||
by repository policy. Deleted/expired artifacts permit another attempt, so
|
||||
|
|
@ -224,6 +233,8 @@ no Python implementation or additional Go module dependency is required.
|
|||
|
||||
- [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)
|
||||
- [GitHub exact-name artifact filter](https://docs.github.com/en/rest/actions/artifacts#list-artifacts-for-a-repository)
|
||||
- [SemVer prerelease precedence](https://semver.org/)
|
||||
- [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)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
|
|
@ -58,11 +58,11 @@ func (e *engine) duplicateArtifact(ctx context.Context, reservation string) (boo
|
|||
Expired bool `json:"expired"`
|
||||
} `json:"artifacts"`
|
||||
}
|
||||
if err := e.github(ctx, fmt.Sprintf("/actions/artifacts?per_page=100&page=%d", page), &response); err != nil {
|
||||
if err := e.github(ctx, fmt.Sprintf("/actions/artifacts?name=%s&per_page=100&page=%d", url.QueryEscape(reservation), page), &response); err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, a := range response.Artifacts {
|
||||
if !a.Expired && (a.Name == reservation || strings.HasPrefix(a.Name, reservation+"-")) {
|
||||
if !a.Expired && a.Name == reservation {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ func (e *engine) duplicateArtifact(ctx context.Context, reservation string) (boo
|
|||
return false, nil
|
||||
}
|
||||
}
|
||||
return false, errors.New("artifact ledger exceeds lookup limit; manual review required")
|
||||
return false, errors.New("matching reservation ledger exceeds lookup limit; manual review required")
|
||||
}
|
||||
|
||||
func actionsOutput(key, value string) error {
|
||||
|
|
@ -166,7 +166,7 @@ func (e *engine) prepareGitHub(ctx context.Context, ev event) error {
|
|||
if err := e.saveSources(sources, m); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := actionsOutput("reservation", m.Reservation+"-"+m.RunID+"-"+m.RunAttempt); err != nil {
|
||||
if err := actionsOutput("reservation", m.Reservation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := actionsOutput("prepared", "true"); err != nil {
|
||||
|
|
|
|||
|
|
@ -196,6 +196,57 @@ func TestInclusiveRangeSelection(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSemVerPrereleasePrecedence(t *testing.T) {
|
||||
ordered := []string{"v1.0.0-alpha", "v1.0.0-alpha.1", "v1.0.0-alpha.beta", "v1.0.0-beta", "v1.0.0-beta.2", "v1.0.0-beta.11", "v1.0.0-rc.1", "v1.0.0"}
|
||||
for i, a := range ordered {
|
||||
for j, b := range ordered {
|
||||
order := compareVersion(a, b)
|
||||
if (i < j && order >= 0) || (i == j && order != 0) || (i > j && order <= 0) {
|
||||
t.Fatalf("incorrect precedence for %s and %s: %d", a, b, order)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pair := range [][2]string{{"v1.0.0-rc.2", "v1.0.0-rc.10"}, {"v1.0.0-99999999999999999999", "v1.0.0-100000000000000000000"}, {"v1.0.0-9", "v1.0.0-alpha"}, {"v1.0.0-alpha.beta", "v1.0.0-alpha-beta"}, {"v1.0.0", "v1.0.1-alpha"}} {
|
||||
if _, err := version(pair[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compareVersion(pair[0], pair[1]) >= 0 {
|
||||
t.Fatal("incorrect numeric/identifier precedence", pair)
|
||||
}
|
||||
}
|
||||
for _, tag := range []string{"v1.0.0-01", "v1.0.0-rc.01", "v1.0.0-rc..1", "v1.0.0-"} {
|
||||
if _, err := normalizeTag(tag); err == nil {
|
||||
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) {
|
||||
e, records, _ := testEngine(t)
|
||||
selected := []releaseRecord{records[1], records[0]}
|
||||
for i := range 2 {
|
||||
r := records[i]
|
||||
r.ID += 100
|
||||
r.Tag += "-rc.1"
|
||||
r.Prerelease = true
|
||||
selected = append(selected, r)
|
||||
}
|
||||
data, _ := json.Marshal(selected)
|
||||
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.1", includePrereleases: true})
|
||||
if err != nil || len(sources) != 3 || sources[0].Tag != "v0.64.0" || sources[1].Tag != "v0.64.1-rc.1" || sources[2].Tag != "v0.64.1" {
|
||||
t.Fatal("prerelease at lower bound must be excluded, upper-bound RC included", sources, err)
|
||||
}
|
||||
sources, err = e.resolveLocal(t.Context(), options{from: "v0.64.0", to: "v0.64.1"})
|
||||
if err != nil || len(sources) != 2 {
|
||||
t.Fatal("prerelease opt-in was bypassed", sources, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeDiscardsDraftsAndFailsClosedAtLimit(t *testing.T) {
|
||||
e, records, _ := testEngine(t)
|
||||
draft := records[0]
|
||||
|
|
@ -397,6 +448,31 @@ func TestUnsafeModelOutputAndSourceMarkup(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestQualifierChecksUseVisibleNotes(t *testing.T) {
|
||||
warnings := []string{"back up your database before upgrading", "may need to re-sync", "experimental Jellyfin", "Plugin authors must migrate: Extism networking is disabled", "security release: Upgrade now", "opt-in LAN auto-discovery", "slow storage", "32-bit builds"}
|
||||
for _, warning := range warnings {
|
||||
t.Run(warning, func(t *testing.T) {
|
||||
s := source{SourceID: "1", Tag: "v1.0.0", Body: "Visible improvements.\n<!--\n" + warning + "\n-->"}
|
||||
sentences := []sentence{{SourceID: "1", Text: "Version 1.0.0 contains improvements."}}
|
||||
if strings.Contains(promptSources([]source{s})[0]["body"], warning) || len(cautions([]source{s})) != 0 {
|
||||
t.Fatal("hidden warnings entered model input or cautions")
|
||||
}
|
||||
if err := validateQualifiers(sentences, []source{s}); err != nil {
|
||||
t.Fatal("hidden warning required unseen evidence", err)
|
||||
}
|
||||
s.Body = "Visible improvements.\n" + warning
|
||||
if err := validateQualifiers(sentences, []source{s}); err == nil {
|
||||
t.Fatal("visible warning no longer enforced")
|
||||
}
|
||||
})
|
||||
}
|
||||
_, sources := fixtures(t)
|
||||
sources[2].Body += "\n<!-- back up your database before upgrading -->"
|
||||
if _, err := validateNarration(exampleNarration(t, sources), sources); err != nil {
|
||||
t.Fatal("full narration rejected because of an unseen comment", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedSourcesAndCheckpointsStopPaidStages(t *testing.T) {
|
||||
e, records, sources := testEngine(t)
|
||||
cfg, _ := reviewedConfig("gpt-6-luna", "tts-1", "onyx", "audio")
|
||||
|
|
@ -587,7 +663,7 @@ func TestActionsEnablementAndConfigChanges(t *testing.T) {
|
|||
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}}})
|
||||
body, _ := json.Marshal(map[string]any{"artifacts": []any{map[string]any{"name": "key", "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 {
|
||||
|
|
@ -596,7 +672,7 @@ func TestArtifactDuplicatesExpiryAndLookupBounds(t *testing.T) {
|
|||
}
|
||||
artifacts := make([]map[string]any, 100)
|
||||
for i := range artifacts {
|
||||
artifacts[i] = map[string]any{"name": "other", "expired": false}
|
||||
artifacts[i] = map[string]any{"name": "key", "expired": true}
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"artifacts": artifacts})
|
||||
calls := 0
|
||||
|
|
@ -609,6 +685,76 @@ func TestArtifactDuplicatesExpiryAndLookupBounds(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestArtifactLookupFiltersUnrelatedHistoryAndFailsClosed(t *testing.T) {
|
||||
e, _, _ := testEngine(t)
|
||||
calls := 0
|
||||
e.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if req.URL.Query().Get("name") != "key" || req.URL.Query().Get("page") != "1" {
|
||||
t.Fatal("lookup walked unrelated repository history", req.URL)
|
||||
}
|
||||
// A repository can have more than 10,000 unrelated artifacts. The API
|
||||
// returns only this exact reservation name, so none enter pagination.
|
||||
return response(200, "application/json", []byte(`{"total_count":0,"artifacts":[]}`)), nil
|
||||
})
|
||||
if duplicate, err := e.duplicateArtifact(t.Context(), "key"); err != nil || duplicate || calls != 1 {
|
||||
t.Fatal(duplicate, calls, err)
|
||||
}
|
||||
e.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return response(503, "application/json", nil), nil
|
||||
})
|
||||
if _, err := e.duplicateArtifact(t.Context(), "key"); err == nil {
|
||||
t.Fatal("artifact API failure permitted generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsDeterministicReservationAndForce(t *testing.T) {
|
||||
e, records, _ := testEngine(t)
|
||||
actionsEnvironment(t, records[0], true)
|
||||
original := e.client.Transport
|
||||
e.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(req.URL.Path, "/actions/artifacts") {
|
||||
name := req.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
t.Fatal("reservation lookup must use exact-name filtering")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"artifacts": []any{map[string]any{"name": name, "expired": false}}})
|
||||
return response(200, "application/json", body), nil
|
||||
}
|
||||
return original.RoundTrip(req)
|
||||
})
|
||||
if err := e.runGitHub(t.Context(), "prepare"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := e.readJSON("manifest.json", &m); err != nil || m.Status != "duplicate" {
|
||||
t.Fatal(m, err)
|
||||
}
|
||||
outputs, err := os.ReadFile(os.Getenv("GITHUB_OUTPUT"))
|
||||
if err != nil || !strings.Contains(string(outputs), "reservation="+m.Reservation+"\n") || !strings.Contains(string(outputs), "generate=false\n") {
|
||||
t.Fatal("reservation upload name or duplicate guard changed", string(outputs), err)
|
||||
}
|
||||
ev, err := githubEvent()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev.Inputs.Force = "true"
|
||||
data, _ := json.Marshal(ev)
|
||||
if err := os.WriteFile(os.Getenv("GITHUB_EVENT_PATH"), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(os.Getenv("GITHUB_OUTPUT"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.runGitHub(t.Context(), "prepare"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputs, err = os.ReadFile(os.Getenv("GITHUB_OUTPUT"))
|
||||
if err != nil || !strings.Contains(string(outputs), "reservation="+m.Reservation+"\n") || !strings.Contains(string(outputs), "generate=true\n") {
|
||||
t.Fatal("explicit force did not permit a new reserved attempt", string(outputs), 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)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
|
||||
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]+)*)?$`)
|
||||
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"`
|
||||
|
|
@ -53,6 +53,13 @@ func version(tag string) ([3]uint64, error) {
|
|||
}
|
||||
v[i] = n
|
||||
}
|
||||
if matches[4] != "" {
|
||||
for _, identifier := range strings.Split(matches[4], ".") {
|
||||
if numericIdentifier(identifier) && len(identifier) > 1 && identifier[0] == '0' {
|
||||
return v, errors.New("numeric prerelease identifiers must not have leading zeros")
|
||||
}
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
|
|
@ -76,9 +83,48 @@ func compareVersion(a, b string) int {
|
|||
return 1
|
||||
}
|
||||
}
|
||||
_, pa, _ := strings.Cut(a, "-")
|
||||
_, pb, _ := strings.Cut(b, "-")
|
||||
return comparePrerelease(pa, pb)
|
||||
}
|
||||
|
||||
func numericIdentifier(s string) bool { return strings.Trim(s, "0123456789") == "" }
|
||||
|
||||
func compareIdentifiers(a, b string) int {
|
||||
an, bn := numericIdentifier(a), numericIdentifier(b)
|
||||
if an != bn {
|
||||
if an {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// Numeric identifiers are valid without leading zeros; length then lexical
|
||||
// comparison handles arbitrarily large identifiers without integer overflow.
|
||||
if an && len(a) != len(b) {
|
||||
return len(a) - len(b)
|
||||
}
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
|
||||
func comparePrerelease(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
if a == "" {
|
||||
return 1 // A stable release follows every prerelease of the same version.
|
||||
}
|
||||
if b == "" {
|
||||
return -1
|
||||
}
|
||||
ai, bi := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := range min(len(ai), len(bi)) {
|
||||
if order := compareIdentifiers(ai[i], bi[i]); order != 0 {
|
||||
return order
|
||||
}
|
||||
}
|
||||
return len(ai) - len(bi)
|
||||
}
|
||||
|
||||
func parseTags(raw string) ([]string, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
if len(parts) > 3 {
|
||||
|
|
|
|||
|
|
@ -34,10 +34,12 @@ 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 visibleNotes(body string) string { return htmlComments.ReplaceAllString(body, "") }
|
||||
|
||||
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, "")})
|
||||
result = append(result, map[string]string{"source_id": s.SourceID, "tag": s.Tag, "body": visibleNotes(s.Body)})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -46,7 +48,7 @@ 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") {
|
||||
for _, line := range strings.Split(visibleNotes(s.Body), "\n") {
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
migration = migrationHeader.MatchString(line)
|
||||
}
|
||||
|
|
@ -124,7 +126,7 @@ func validateNarration(result narration, sources []source) (string, error) {
|
|||
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) {
|
||||
if !ok || strings.TrimSpace(s.Text) == "" || len(s.Excerpt) < 12 || !strings.Contains(visibleNotes(original.Body), s.Excerpt) {
|
||||
return "", errors.New("sentence evidence is absent from its published source")
|
||||
}
|
||||
texts = append(texts, strings.TrimSpace(s.Text))
|
||||
|
|
@ -194,7 +196,7 @@ 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)
|
||||
body := strings.NewReplacer("*", "", "`", "").Replace(visibleNotes(s.Body))
|
||||
for _, rule := range qualifierRules {
|
||||
if !regexp.MustCompile("(?is)" + rule.trigger).MatchString(body) {
|
||||
continue
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue