Skip to content

Commit 8995cc8

Browse files
committed
schedule-builder: optionally extend upcoming patch window to 4 during maintenance overlap
When the oldest supported minor (N-2) enters its maintenance window (i.e. maintenanceModeStartDate <= today < endOfLifeDate), the next minor (N+1) has typically already shipped, so four patch releases are concurrently in flight. In that case three upcoming monthly patch entries no longer cover the cycle. Detect the overlap on the oldest entry in schedules[] and prompt the operator (stderr, [y/N]) during --update. On confirmation, lift the upcoming_releases[] cap from 3 to 4 for that run. TBD or unparseable dates skip the prompt entirely. The schedules[] array is left alone: adding a new minor entry remains a manual sig-release step. The prompter is a package-level variable so tests can inject a deterministic answer without touching stdin. Signed-off-by: Nabarun Pal <pal.nabarun95@gmail.com>
1 parent ac9f3cb commit 8995cc8

2 files changed

Lines changed: 196 additions & 2 deletions

File tree

cmd/schedule-builder/cmd/markdown.go

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ limitations under the License.
1717
package cmd
1818

1919
import (
20+
"bufio"
2021
"bytes"
2122
"embed"
23+
"errors"
2224
"fmt"
25+
"io"
2326
"os"
2427
"slices"
2528
"strings"
@@ -188,8 +191,52 @@ const (
188191
# schedule-builder -uc data/releases/schedule.yaml -e data/releases/eol.yaml
189192
---
190193
`
194+
defaultUpcomingReleases = 3
195+
extendedUpcomingReleases = 4
191196
)
192197

198+
// isInMaintenanceWindow reports whether refTime sits between sched's
199+
// maintenanceModeStartDate (inclusive) and endOfLifeDate (exclusive).
200+
// Unparseable values (commonly "TBD") return false.
201+
func isInMaintenanceWindow(refTime time.Time, sched *Schedule) bool {
202+
if sched == nil || sched.MaintenanceModeStartDate == "" || sched.EndOfLifeDate == "" {
203+
return false
204+
}
205+
206+
mms, err := time.Parse(refDate, sched.MaintenanceModeStartDate)
207+
if err != nil {
208+
return false
209+
}
210+
211+
eol, err := time.Parse(refDate, sched.EndOfLifeDate)
212+
if err != nil {
213+
return false
214+
}
215+
216+
return !refTime.Before(mms) && refTime.Before(eol)
217+
}
218+
219+
// confirmExtraUpcoming asks the operator whether to extend the upcoming
220+
// patch window to 4 entries during a maintenance-mode overlap. Overridable
221+
// in tests.
222+
var confirmExtraUpcoming = func(oldest *Schedule) (bool, error) {
223+
fmt.Fprintf(os.Stderr,
224+
"Oldest supported release %s is in its maintenance window "+
225+
"(maintenanceModeStartDate=%s, endOfLifeDate=%s).\n"+
226+
"Generate an additional upcoming patch release entry (%d total instead of %d)? [y/N]: ",
227+
oldest.Release, oldest.MaintenanceModeStartDate, oldest.EndOfLifeDate,
228+
extendedUpcomingReleases, defaultUpcomingReleases)
229+
230+
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
231+
if err != nil && !errors.Is(err, io.EOF) {
232+
return false, fmt.Errorf("read confirmation: %w", err)
233+
}
234+
235+
answer := strings.TrimSpace(strings.ToLower(line))
236+
237+
return answer == "y" || answer == "yes", nil
238+
}
239+
193240
//nolint:maintidx // complex but acceptable
194241
func updatePatchSchedule(refTime time.Time, schedule PatchSchedule, eolBranches EolBranches, filePath, eolFilePath string) error {
195242
removeSchedules := []int{}
@@ -321,6 +368,23 @@ func updatePatchSchedule(refTime time.Time, schedule PatchSchedule, eolBranches
321368

322369
schedule.Schedules = newSchedules
323370

371+
upcomingCap := defaultUpcomingReleases
372+
373+
if len(schedule.Schedules) > 0 {
374+
oldest := schedule.Schedules[len(schedule.Schedules)-1]
375+
if isInMaintenanceWindow(refTime, oldest) {
376+
ok, err := confirmExtraUpcoming(oldest)
377+
if err != nil {
378+
return err
379+
}
380+
381+
if ok {
382+
upcomingCap = extendedUpcomingReleases
383+
logrus.Infof("Extending upcoming patch window to %d entries while %s is in maintenance mode", extendedUpcomingReleases, oldest.Release)
384+
}
385+
}
386+
}
387+
324388
newUpcomingReleases := []*PatchRelease{}
325389
latestDate := refTime
326390

@@ -342,8 +406,8 @@ func updatePatchSchedule(refTime time.Time, schedule PatchSchedule, eolBranches
342406
}
343407

344408
for {
345-
if len(newUpcomingReleases) >= 3 {
346-
logrus.Infof("Got 3 new upcoming releases, not adding any more")
409+
if len(newUpcomingReleases) >= upcomingCap {
410+
logrus.Infof("Got %d new upcoming releases, not adding any more", upcomingCap)
347411

348412
break
349413
}

cmd/schedule-builder/cmd/markdown_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package cmd
1818

1919
import (
20+
"errors"
2021
"fmt"
2122
"os"
2223
"testing"
@@ -482,3 +483,132 @@ func TestUpdatePatchSchedule(t *testing.T) {
482483
})
483484
}
484485
}
486+
487+
func TestUpdatePatchScheduleMaintenanceWindow(t *testing.T) {
488+
refTime := time.Date(2026, 5, 24, 0, 0, 0, 0, time.UTC)
489+
490+
// Four supported minors with the oldest (1.30) sitting in its
491+
// maintenance window relative to refTime. Each Next.TargetDate is
492+
// after refTime so the per-schedule patch-bump loop short-circuits.
493+
baseSchedule := func() PatchSchedule {
494+
return PatchSchedule{
495+
Schedules: []*Schedule{
496+
{
497+
Release: "1.33",
498+
Next: &PatchRelease{
499+
Release: "1.33.1",
500+
CherryPickDeadline: "2026-06-05",
501+
TargetDate: "2026-06-09",
502+
},
503+
EndOfLifeDate: "2027-06-01",
504+
MaintenanceModeStartDate: "2027-03-01",
505+
},
506+
{
507+
Release: "1.32",
508+
Next: &PatchRelease{
509+
Release: "1.32.5",
510+
CherryPickDeadline: "2026-06-05",
511+
TargetDate: "2026-06-09",
512+
},
513+
EndOfLifeDate: "2027-02-01",
514+
MaintenanceModeStartDate: "2026-11-01",
515+
},
516+
{
517+
Release: "1.31",
518+
Next: &PatchRelease{
519+
Release: "1.31.10",
520+
CherryPickDeadline: "2026-06-05",
521+
TargetDate: "2026-06-09",
522+
},
523+
EndOfLifeDate: "2026-10-01",
524+
MaintenanceModeStartDate: "2026-08-01",
525+
},
526+
{
527+
Release: "1.30",
528+
Next: &PatchRelease{
529+
Release: "1.30.15",
530+
CherryPickDeadline: "2026-06-05",
531+
TargetDate: "2026-06-09",
532+
},
533+
EndOfLifeDate: "2026-07-01",
534+
MaintenanceModeStartDate: "2026-04-01",
535+
},
536+
},
537+
}
538+
}
539+
540+
for _, tc := range []struct {
541+
name string
542+
confirm func(*Schedule) (bool, error)
543+
mutate func(PatchSchedule) PatchSchedule
544+
wantUpcomingCount int
545+
wantErr string
546+
}{
547+
{
548+
name: "overlap accepted - extends upcoming to 4",
549+
confirm: func(*Schedule) (bool, error) { return true, nil },
550+
wantUpcomingCount: 4,
551+
},
552+
{
553+
name: "overlap declined - upcoming stays at 3",
554+
confirm: func(*Schedule) (bool, error) { return false, nil },
555+
wantUpcomingCount: 3,
556+
},
557+
{
558+
name: "TBD maintenance date - prompter never called",
559+
confirm: func(*Schedule) (bool, error) {
560+
t.Fatal("confirmExtraUpcoming must not be called when MaintenanceModeStartDate is TBD")
561+
562+
return false, nil
563+
},
564+
mutate: func(s PatchSchedule) PatchSchedule {
565+
s.Schedules[3].MaintenanceModeStartDate = "TBD"
566+
567+
return s
568+
},
569+
wantUpcomingCount: 3,
570+
},
571+
{
572+
name: "prompt error bubbles up",
573+
confirm: func(*Schedule) (bool, error) { return false, errors.New("boom") },
574+
wantErr: "boom",
575+
},
576+
} {
577+
t.Run(tc.name, func(t *testing.T) {
578+
orig := confirmExtraUpcoming
579+
580+
t.Cleanup(func() {
581+
confirmExtraUpcoming = orig
582+
})
583+
584+
confirmExtraUpcoming = tc.confirm
585+
586+
sched := baseSchedule()
587+
if tc.mutate != nil {
588+
sched = tc.mutate(sched)
589+
}
590+
591+
scheduleFile, err := os.CreateTemp(t.TempDir(), "schedule-")
592+
require.NoError(t, err)
593+
require.NoError(t, scheduleFile.Close())
594+
595+
err = updatePatchSchedule(refTime, sched, EolBranches{}, scheduleFile.Name(), "")
596+
if tc.wantErr != "" {
597+
require.ErrorContains(t, err, tc.wantErr)
598+
599+
return
600+
}
601+
602+
require.NoError(t, err)
603+
604+
scheduleYamlBytes, err := os.ReadFile(scheduleFile.Name()) //nolint:gosec // G304 - temp file path is safe
605+
require.NoError(t, err)
606+
607+
patchRes := PatchSchedule{}
608+
require.NoError(t, yaml.UnmarshalStrict(scheduleYamlBytes, &patchRes))
609+
610+
assert.Len(t, patchRes.UpcomingReleases, tc.wantUpcomingCount)
611+
assert.Len(t, patchRes.Schedules, 4, "schedules[] count must be unchanged")
612+
})
613+
}
614+
}

0 commit comments

Comments
 (0)