diff --git a/.github/workflows/release-podcast.yml b/.github/workflows/release-podcast.yml index 30be940d4..035799240 100644 --- a/.github/workflows/release-podcast.yml +++ b/.github/workflows/release-podcast.yml @@ -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 diff --git a/release/podcast/README.md b/release/podcast/README.md index 9b22081c8..7f9a1174a 100644 --- a/release/podcast/README.md +++ b/release/podcast/README.md @@ -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) diff --git a/release/podcast/actions.go b/release/podcast/actions.go index d106e1caa..0c6d132d7 100644 --- a/release/podcast/actions.go +++ b/release/podcast/actions.go @@ -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 { diff --git a/release/podcast/podcast_test.go b/release/podcast/podcast_test.go index 81011a4dc..f9f2d6b46 100644 --- a/release/podcast/podcast_test.go +++ b/release/podcast/podcast_test.go @@ -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"} + 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" + 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) diff --git a/release/podcast/source.go b/release/podcast/source.go index fc2416118..542915a30 100644 --- a/release/podcast/source.go +++ b/release/podcast/source.go @@ -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 { diff --git a/release/podcast/validation.go b/release/podcast/validation.go index b6917a679..dd92141ba 100644 --- a/release/podcast/validation.go +++ b/release/podcast/validation.go @@ -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