Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions .github/scripts/delete-workflow-runs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,12 @@ func (a api) workflowAbsent(ctx context.Context, prefix string, id int64) (bool,
return false, nil
}

var searchCapped = errors.New("run search reaches GitHub's 1,000-result limit")
type searchCapped struct{ minimum int }

// Error preserves the cap diagnostic while the retained minimum guides splitting.
func (e *searchCapped) Error() string {
return "run search reaches GitHub's 1,000-result limit"
}

// list requires stable totals, unique identities and complete item shapes on every page.
// Page one establishes the minimum page count; up to four required pages then
Expand Down Expand Up @@ -357,7 +362,7 @@ func (a api) list(ctx context.Context, path, key string) ([]json.RawMessage, err
return errors.New("listing count exceeds declared total")
}
if key == "workflow_runs" && strings.Contains(path, "?created=") && count >= 1000 {
return searchCapped
return &searchCapped{minimum: count}
}
for _, item := range items {
var fields map[string]json.RawMessage
Expand Down Expand Up @@ -436,15 +441,25 @@ func (a api) listRuns(ctx context.Context, path string, now time.Time) ([]json.R
}
stamp := func(second int64) string { return time.Unix(second, 0).UTC().Format(time.RFC3339) }
items, err := a.list(ctx, path+"?created="+url.QueryEscape(stamp(lower)+".."+stamp(upper)), "workflow_runs")
if errors.Is(err, searchCapped) {
var capped *searchCapped
if errors.As(err, &capped) {
if lower == upper {
return errors.New("run search cannot prove completeness within one second")
}
before := len(result)
middle := lower + (upper-lower)/2
if err := visit(lower, middle); err != nil {
return err
}
return visit(middle+1, upper)
if err := visit(middle+1, upper); err != nil {
return err
}
// A capped count is a lower bound, not equality. Each split must
// account for its own minimum before any cleanup can use the plan.
if len(result)-before < capped.minimum {
return errors.New("split history is below its capped minimum")
}
return nil
}
if err != nil {
return err
Expand Down
136 changes: 136 additions & 0 deletions .github/scripts/delete-workflow-runs/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1127,3 +1127,139 @@ func TestSharedSnapshotRetainsMinimumForEachWorkflow(t *testing.T) {
})
}
}

// Capped totals are lower bounds. Every descendant must be observed before
// a partial plan may reach either a mutation or a completion receipt.
func TestCappedHistoryCannotShrinkBelowItsMinimum(t *testing.T) {
for _, test := range []struct {
name string
parent, descendants int
accept bool
}{
{"empty", 1000, 0, false},
{"one", 1000, 1, false},
{"one-short", 1000, 999, false},
{"exact-minimum", 1000, 1000, true},
{"above-minimum", 1000, 1001, true},
{"larger-advertised-minimum", 1500, 1001, false},
} {
t.Run(test.name, func(t *testing.T) {
cutoff := fixedNow.Add(-time.Second).Unix()
stamp := func(second int64) string { return time.Unix(second, 0).UTC().Format(time.RFC3339) }
whole := stamp(0) + ".." + stamp(cutoff)
left := stamp(0) + ".." + stamp(cutoff/2)
right := stamp(cutoff/2+1) + ".." + stamp(cutoff)
older, newer := []string{}, []string{}
for id := 1; id <= test.descendants; id++ {
if id <= (test.descendants+1)/2 {
older = append(older, runJSON(id, "1990-01-01T00:00:00Z", "completed", "success"))
} else {
newer = append(newer, oldRun(id))
}
}
var writes atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
writes.Add(1)
w.WriteHeader(204)
return
}
if r.URL.Path == fixtureRepo+"/actions/workflows" {
_, _ = io.WriteString(w, workflowBody)
return
}
var runs []string
switch r.URL.Query().Get("created") {
case whole:
_, _ = fmt.Fprintf(w, `{"total_count":%d,"workflow_runs":[]}`, test.parent)
return
case left:
runs = older
case right:
runs = newer
default:
t.Errorf("unexpected range: %s", r.URL.RequestURI())
w.WriteHeader(403)
return
}
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 || (page-1)*100 > len(runs) {
w.WriteHeader(403)
return
}
start := (page - 1) * 100
_, _ = fmt.Fprintf(w, `{"total_count":%d,"workflow_runs":[%s]}`, len(runs), strings.Join(runs[start:min(start+100, len(runs))], ","))
}))
defer server.Close()
cfg := baseConfig()
cfg.minimum, cfg.dryRun = 0, test.accept
var log bytes.Buffer
err := clean(context.Background(), cfg, server.URL, "offline-fixture-token", server.Client(), fixedNow, &log, func(context.Context, time.Duration) error { return nil })
if test.accept {
if err != nil || !strings.Contains(log.String(), fmt.Sprintf("Cleanup completed: selected=%d dry-run=true", test.descendants)) {
t.Fatalf("complete capped history rejected: err=%v log bytes=%d", err, log.Len())
}
} else if err == nil || !strings.Contains(err.Error(), "capped minimum") || log.Len() != 0 {
t.Fatalf("incomplete split accepted: err=%v writes=%d log bytes=%d", err, writes.Load(), log.Len())
}
if writes.Load() != 0 {
t.Fatalf("partial or dry-run history reached %d DELETEs", writes.Load())
}
})
}
}

func TestEarlierSiblingCannotHideNestedCappedShortage(t *testing.T) {
cutoff := fixedNow.Add(-time.Second).Unix()
middle := cutoff / 2
stamp := func(second int64) string { return time.Unix(second, 0).UTC().Format(time.RFC3339) }
whole := stamp(0) + ".." + stamp(cutoff)
left := stamp(0) + ".." + stamp(middle)
right := stamp(middle+1) + ".." + stamp(cutoff)
nestedMiddle := middle + 1 + (cutoff-middle-1)/2
nestedLeft := stamp(middle+1) + ".." + stamp(nestedMiddle)
nestedRight := stamp(nestedMiddle+1) + ".." + stamp(cutoff)
older := []string{}
for id := 1; id <= 999; id++ {
older = append(older, runJSON(id, "1990-01-01T00:00:00Z", "completed", "success"))
}
var writes atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
writes.Add(1)
w.WriteHeader(204)
return
}
if r.URL.Path == fixtureRepo+"/actions/workflows" {
_, _ = io.WriteString(w, workflowBody)
return
}
switch r.URL.Query().Get("created") {
case whole, right:
_, _ = io.WriteString(w, `{"total_count":1000,"workflow_runs":[]}`)
case left:
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
start := (page - 1) * 100
if page < 1 || start >= len(older) {
w.WriteHeader(403)
return
}
_, _ = fmt.Fprintf(w, `{"total_count":999,"workflow_runs":[%s]}`, strings.Join(older[start:min(start+100, len(older))], ","))
case nestedLeft:
_, _ = io.WriteString(w, runBody(runJSON(1000, "2000-01-01T00:00:00Z", "completed", "success")))
case nestedRight:
_, _ = io.WriteString(w, runBody())
default:
t.Errorf("unexpected range: %s", r.URL.RequestURI())
w.WriteHeader(403)
}
}))
defer server.Close()
cfg := baseConfig()
cfg.minimum, cfg.dryRun = 0, false
var log bytes.Buffer
err := clean(context.Background(), cfg, server.URL, "offline-fixture-token", server.Client(), fixedNow, &log, func(context.Context, time.Duration) error { return nil })
if err == nil || !strings.Contains(err.Error(), "capped minimum") || writes.Load() != 0 || log.Len() != 0 {
t.Fatalf("earlier sibling hid partial nested inventory: err=%v writes=%d log bytes=%d", err, writes.Load(), log.Len())
}
}
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ projection by hand. Input behavior and credential restoration have independent
negative controls. The owned Go driver under `.github/scripts/delete-workflow-runs/`
completes enumeration and individual orphan-parent revalidation before mutation,
freezes run creation time and partitions searches at GitHub's 1,000-result cap,
requires every split to recover its own advertised minimum before deletion,
uses the complete repository snapshot for every workflow's retention decisions,
retries transient reads twice and never replays a deletion. Newly observed workflows
retain their runs; unknown absence fails closed. Its real HTTP and native-command tests run through the same
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,9 @@ Every workflow's retention decision uses that same complete repository-run snaps
avoiding repeated scans of the same history through separate workflow endpoints.
Run enumeration ends at the last complete second before cleanup starts, so new
runs wait for the next cleanup instead of shifting pagination. Searches reaching
GitHub's 1,000-result limit are split into disjoint creation-time ranges; a range
that still reaches the limit within one second fails rather than skipping history.
GitHub's 1,000-result limit are split into disjoint creation-time ranges. Each split
must recover at least the count its parent advertised before cleanup may proceed.
A range that still reaches the limit within one second fails rather than skipping history.
The newest `minimum-runs` matching old runs are retained in addition to recent runs.
Runs whose workflow is no longer listed are selected independently of those filters
only after an individual workflow lookup confirms absence. Workflows that appear
Expand Down
Loading