fix(release): correct podcast reservations and prerelease validation

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

View file

@ -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

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

@ -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