#13 Cloudflare Cron Trigger: Friday 17:00 America/Chicago, DST-correct

Add a weekly Cron Trigger that fires at 17:00 America/Chicago (Central) every
Friday year-round, correct across the CST/CDT DST transition, and on fire lists
the pending reports (#10 Manager.ListPending) and hands their ids to the publish
step.

Cloudflare crons are UTC-only, and 17:00 Central is 22:00 UTC under CDT (summer)
and 23:00 UTC under CST (winter), so no single UTC cron expresses it. Register
BOTH Friday UTC hours in wrangler.jsonc (`0 22 * * 5` and `0 23 * * 5`) and gate
each fire: only the fire that is actually 17:00 Central does the work, so
publishing runs exactly once per Friday.

TinyGo/Wasm may lack the IANA tz database, so the gate does not call
time.LoadLocation. Instead internal/schedule computes the US Central DST rule
from first principles (CDT from the 2nd Sunday of March 02:00 to the 1st Sunday
of November 02:00, else CST) behind a pure func IsFriday1700Central(time.Time),
host-testable without TinyGo and cross-checked against the real America/Chicago
zone (via a test-only time/tzdata import) over a 20-year sweep.

- internal/schedule: pure DST gate + Run orchestrator; Publisher/PendingLister
  seams; LogPublisher no-op default (the seam #14 replaces).
- worker/scheduled_wasm.go: js/wasm-only adapter registering the scheduled task
  via init()+cron.ScheduleTaskNonBlock, wiring the R2-backed lifecycle Manager to
  schedule.Run. worker/main.go is untouched.
- wrangler.jsonc: add triggers.crons (only the triggers section changed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:52:39 -05:00
committed by Jason Ross
co-authored by Claude Opus 4.8
parent c9f8489350
commit e650e5f161
4 changed files with 572 additions and 1 deletions
+169
View File
@@ -0,0 +1,169 @@
// Package schedule holds the timezone gate and orchestration for the weekly
// publish trigger (#13): "every Friday at 17:00 America/Chicago (Central), list
// the pending reports and hand them to the publish step".
//
// # Why a gate is needed at all
//
// Cloudflare Cron Triggers are evaluated in UTC and have no timezone support, so
// a single UTC cron cannot express "17:00 America/Chicago" year-round: Central is
// UTC-5 during Daylight time (CDT, summer) and UTC-6 during Standard time (CST,
// winter), so 17:00 Central is 22:00 UTC for part of the year and 23:00 UTC for
// the rest. The Worker therefore registers BOTH candidate Friday UTC hours in
// wrangler.jsonc (`0 22 * * 5` and `0 23 * * 5`) and gates each fire with
// [IsFriday1700Central]: exactly one of the two fires is 17:00 Central on any
// given Friday, so [Run] does the publish work exactly once per week across the
// CST/CDT boundary and does nothing on the other fire.
//
// # No timezone database needed
//
// The gate deliberately does not call time.LoadLocation("America/Chicago"): the
// Worker is compiled to Wasm by TinyGo, whose runtime may not embed the IANA
// tz database, so a LoadLocation could fail at deploy time. Instead the US
// Central DST rule is computed from first principles (see centralIsDaylight):
// Daylight time runs from the 2nd Sunday of March at 02:00 to the 1st Sunday of
// November at 02:00. This keeps the decision a pure function of a time.Time with
// no external data, so it is host-testable exhaustively without TinyGo (and the
// tests cross-check it against the real IANA zone via time/tzdata).
package schedule
import (
"context"
"fmt"
"log"
"time"
)
// PendingLister is the seam for "list the reports awaiting publication". It is
// satisfied by *lifecycle.Manager (#10) via its ListPending method; tests inject
// a fake. Kept as a one-method interface so this package does not import the
// storage-backed lifecycle package (and its Wasm-only R2 dependencies).
type PendingLister interface {
// ListPending returns the ids of every report currently pending, oldest first.
ListPending(ctx context.Context) ([]string, error)
}
// Publisher is the seam for the publish step (#14), which is not built yet. Run
// hands it the pending ids once the gate says it is time. Implementations turn
// each id into a GitHub issue and mark it published; until #14 lands the default
// [LogPublisher] is wired, so the trigger is exercisable end to end today.
type Publisher interface {
// Publish takes ownership of the pending report ids for this run. The slice may
// be empty (a Friday with no pending reports), which implementations should
// treat as a no-op.
Publish(ctx context.Context, ids []string) error
}
// Run is the body of the scheduled handler. Given the UTC instant a Cron Trigger
// fired for (Cloudflare supplies it as the event's ScheduledTime), it gates on
// [IsFriday1700Central]: only when the fire is 17:00 Central on a Friday does it
// list the pending reports and hand them to publisher.
//
// The returned bool reports whether the gate fired (whether publish work ran),
// which lets the Worker log the "other" UTC fire as an intentional skip and lets
// tests assert the "runs exactly once per Friday" property. A non-nil error is
// the lister's or publisher's error, wrapped.
func Run(ctx context.Context, scheduledFor time.Time, lister PendingLister, publisher Publisher) (ran bool, err error) {
if !IsFriday1700Central(scheduledFor) {
// This is the other candidate UTC fire (or an off-schedule invocation); the
// sibling cron will fire at the correct Central hour. Do nothing.
return false, nil
}
ids, err := lister.ListPending(ctx)
if err != nil {
return true, fmt.Errorf("schedule: list pending: %w", err)
}
if err := publisher.Publish(ctx, ids); err != nil {
return true, fmt.Errorf("schedule: publish: %w", err)
}
return true, nil
}
// IsFriday1700Central reports whether instant t falls on Friday at exactly 17:00
// in America/Chicago (Central), correctly handling the CST/CDT DST transition. It
// is the gate that makes the two-UTC-cron scheme fire the publish job exactly
// once each Friday year-round (see the package doc).
//
// It is a pure function of t with no timezone-database or wall-clock dependency,
// so it is exhaustively unit-testable on the host. t may be in any location; it
// is normalized to UTC first.
func IsFriday1700Central(t time.Time) bool {
c := centralClock(t)
return c.Weekday() == time.Friday && c.Hour() == 17 && c.Minute() == 0
}
// centralClock returns t rendered as America/Chicago wall-clock time. The result
// is a time.Time whose calendar fields (Weekday, Hour, Minute, ...) read as the
// Central wall clock; its Location remains UTC because the shift is applied by
// arithmetic rather than a *time.Location, so callers must read only its fields,
// never treat it as an absolute instant. This trades a tz-database lookup for a
// hand-computed offset so it works under TinyGo/Wasm.
func centralClock(t time.Time) time.Time {
u := t.UTC()
return u.Add(centralOffset(u))
}
// centralOffset is the signed offset from UTC to Central wall-clock time at UTC
// instant u: -5h during Daylight time (CDT), -6h during Standard time (CST).
func centralOffset(u time.Time) time.Duration {
if centralIsDaylight(u) {
return -5 * time.Hour
}
return -6 * time.Hour
}
// centralIsDaylight reports whether UTC instant u falls in US Central Daylight
// Time. Since 2007 US DST runs from the 2nd Sunday of March at 02:00 local
// (Standard) to the 1st Sunday of November at 02:00 local (Daylight). Expressed
// as absolute UTC instants the boundaries are:
//
// - spring forward: 2nd Sunday of March, 08:00 UTC (02:00 CST = UTC-6)
// - fall back: 1st Sunday of November, 07:00 UTC (02:00 CDT = UTC-5)
//
// u is in Daylight time iff it is at or after the spring-forward instant and
// strictly before the fall-back instant. Both boundaries lie in the same UTC
// calendar year as any instant they classify (DST spans March–November), so
// deriving them from u.Year() is correct for every month, including a January
// instant (before spring forward -> Standard) and a December one (after fall
// back -> Standard).
func centralIsDaylight(u time.Time) bool {
year := u.Year()
springForward := time.Date(year, time.March, nthSundayOfMonth(year, time.March, 2), 8, 0, 0, 0, time.UTC)
fallBack := time.Date(year, time.November, nthSundayOfMonth(year, time.November, 1), 7, 0, 0, 0, time.UTC)
return !u.Before(springForward) && u.Before(fallBack)
}
// nthSundayOfMonth returns the day-of-month (1-based) of the nth Sunday in the
// given month and year, e.g. nthSundayOfMonth(2026, time.March, 2) is the 2nd
// Sunday of March 2026. n is assumed >= 1.
func nthSundayOfMonth(year int, month time.Month, n int) int {
first := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
// Days from the 1st to the first Sunday (0 if the 1st is itself a Sunday).
toFirstSunday := (int(time.Sunday) - int(first.Weekday()) + 7) % 7
return 1 + toFirstSunday + (n-1)*7
}
// LogPublisher is the default [Publisher] wired into the Worker until the real
// publish step (#14) exists. It publishes nothing; it only logs that the weekly
// trigger fired and how many reports it would have published, so the scheduled
// path is observable end to end before #14 lands. Report ids are opaque handles
// (a timestamp plus random bytes) and carry nothing sensitive, so logging them is
// safe.
type LogPublisher struct {
// Logf receives the message; it defaults to log.Printf when nil. Tests set it
// to capture output.
Logf func(format string, args ...any)
}
// Publish logs the batch it was handed and returns nil.
func (p LogPublisher) Publish(_ context.Context, ids []string) error {
logf := p.Logf
if logf == nil {
logf = log.Printf
}
logf("schedule: weekly trigger fired; %d pending report(s) to publish "+
"(publish step #14 not implemented yet): %v", len(ids), ids)
return nil
}
// Compile-time check that the default publisher satisfies the seam.
var _ Publisher = LogPublisher{}
+314
View File
@@ -0,0 +1,314 @@
package schedule_test
import (
"context"
"errors"
"slices"
"testing"
"time"
// Embed the IANA tz database into the test binary so
// time.LoadLocation("America/Chicago") works on every host (notably Windows,
// which has no system zoneinfo). This is a TEST-only import: it never reaches
// the Wasm Worker build, whose gate is the hand-rolled rule under test. It lets
// these tests cross-check that hand-rolled rule against the authoritative zone.
_ "time/tzdata"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/schedule"
)
// utc is a terse constructor for a UTC instant.
func utc(y int, mo time.Month, d, h, mi int) time.Time {
return time.Date(y, mo, d, h, mi, 0, 0, time.UTC)
}
// TestIsFriday1700Central is the ticket's core acceptance: the gate returns true
// only at 17:00 America/Chicago on Fridays, across both DST seasons, the days
// bracketing each transition, wrong hours, wrong minutes, and non-Fridays. Every
// UTC instant below was verified against the real America/Chicago zone.
func TestIsFriday1700Central(t *testing.T) {
cases := []struct {
name string
in time.Time
want bool
}{
// --- Summer, CDT (UTC-5): 17:00 Central == 22:00 UTC. Fri 2026-07-10. ---
{"CDT friday 22:00 UTC is 17:00 Central", utc(2026, time.July, 10, 22, 0), true},
// The OTHER candidate cron hour must be false in summer, so work runs once.
{"CDT friday 23:00 UTC is 18:00 Central", utc(2026, time.July, 10, 23, 0), false},
{"CDT friday 21:00 UTC is 16:00 Central", utc(2026, time.July, 10, 21, 0), false},
// --- Winter, CST (UTC-6): 17:00 Central == 23:00 UTC. Fri 2026-01-09. ---
{"CST friday 23:00 UTC is 17:00 Central", utc(2026, time.January, 9, 23, 0), true},
// The OTHER candidate cron hour must be false in winter, so work runs once.
{"CST friday 22:00 UTC is 16:00 Central", utc(2026, time.January, 9, 22, 0), false},
{"CST friday 00:00 UTC sat is 18:00 Central fri", utc(2026, time.January, 10, 0, 0), false},
// --- Around spring forward (2026-03-08 02:00). Fri before is CST, after CDT. ---
{"friday before spring-forward is CST -> 23:00 UTC", utc(2026, time.March, 6, 23, 0), true},
{"friday before spring-forward: 22:00 UTC is 16:00", utc(2026, time.March, 6, 22, 0), false},
{"friday after spring-forward is CDT -> 22:00 UTC", utc(2026, time.March, 13, 22, 0), true},
{"friday after spring-forward: 23:00 UTC is 18:00", utc(2026, time.March, 13, 23, 0), false},
// --- Around fall back (2026-11-01 02:00). Fri before is CDT, after CST. ---
{"friday before fall-back is CDT -> 22:00 UTC", utc(2026, time.October, 30, 22, 0), true},
{"friday before fall-back: 23:00 UTC is 18:00", utc(2026, time.October, 30, 23, 0), false},
{"friday after fall-back is CST -> 23:00 UTC", utc(2026, time.November, 6, 23, 0), true},
{"friday after fall-back: 22:00 UTC is 16:00", utc(2026, time.November, 6, 22, 0), false},
// --- Non-Fridays at the exact Central 17:00 hour must be false. ---
{"thursday 17:00 Central (CDT)", utc(2026, time.July, 9, 22, 0), false},
{"saturday 17:00 Central (CDT)", utc(2026, time.July, 11, 22, 0), false},
{"thursday 17:00 Central (CST)", utc(2026, time.January, 8, 23, 0), false},
// --- Right hour+weekday but wrong minute must be false (17:00 exactly). ---
{"CDT friday 22:30 UTC is 17:30 Central", utc(2026, time.July, 10, 22, 30), false},
{"CST friday 23:01 UTC is 17:01 Central", utc(2026, time.January, 9, 23, 1), false},
// --- A different year's transition, to prove nthSunday isn't hard-coded. ---
// 2027 spring forward is 2027-03-14; Fri 2027-03-12 is still CST.
{"2027 friday before spring-forward is CST", utc(2027, time.March, 12, 23, 0), true},
{"2027 friday before spring-forward: 22:00 is 16:00", utc(2027, time.March, 12, 22, 0), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := schedule.IsFriday1700Central(tc.in); got != tc.want {
t.Errorf("IsFriday1700Central(%s) = %v, want %v",
tc.in.Format(time.RFC3339), got, tc.want)
}
})
}
}
// TestGateInputLocationIndependent proves the gate depends only on the instant,
// not on the time.Time's Location: the same absolute moment expressed in a
// non-UTC zone yields the same answer.
func TestGateInputLocationIndependent(t *testing.T) {
chi := mustLoadChicago(t)
// 17:00 America/Chicago on Fri 2026-07-10, expressed in Central rather than UTC.
central := time.Date(2026, time.July, 10, 17, 0, 0, 0, chi)
if !schedule.IsFriday1700Central(central) {
t.Errorf("gate should be true for 17:00 Central expressed in the Central zone")
}
// And the equivalent UTC instant agrees.
if !schedule.IsFriday1700Central(central.UTC()) {
t.Errorf("gate should be true for the same instant in UTC")
}
}
// TestGateFiresExactlyOncePerFriday proves the two-UTC-cron scheme does the work
// exactly once each Friday year-round: for every Friday across several years,
// exactly one of the two candidate cron hours (22:00 and 23:00 UTC) passes the
// gate — never zero, never both — regardless of which side of a DST boundary the
// Friday falls on.
func TestGateFiresExactlyOncePerFriday(t *testing.T) {
fridays := 0
for d := utc(2024, time.January, 1, 0, 0); d.Year() < 2031; d = d.AddDate(0, 0, 1) {
if d.Weekday() != time.Friday {
continue
}
fridays++
hit22 := schedule.IsFriday1700Central(utc(d.Year(), d.Month(), d.Day(), 22, 0))
hit23 := schedule.IsFriday1700Central(utc(d.Year(), d.Month(), d.Day(), 23, 0))
count := 0
if hit22 {
count++
}
if hit23 {
count++
}
if count != 1 {
t.Errorf("Friday %s: gate passed for %d of the two cron hours (22:00=%v, 23:00=%v), want exactly 1",
d.Format("2006-01-02"), count, hit22, hit23)
}
}
if fridays < 300 {
t.Fatalf("sanity: only iterated %d Fridays, expected ~365", fridays)
}
}
// TestGateMatchesIANAAcrossDSTBoundary is the exhaustive DST verification the
// ticket asks for "without waiting for a real DST change": it sweeps every 30
// minutes across 20 years and asserts the hand-rolled gate agrees with the real
// America/Chicago zone at every instant — including both transition weekends each
// year. If the hand-rolled CST/CDT rule ever drifts from IANA, this fails.
func TestGateMatchesIANAAcrossDSTBoundary(t *testing.T) {
chi := mustLoadChicago(t)
mismatches := 0
var firstMismatch string
for tt := utc(2015, time.January, 1, 0, 0); tt.Year() < 2035; tt = tt.Add(30 * time.Minute) {
inChi := tt.In(chi)
want := inChi.Weekday() == time.Friday && inChi.Hour() == 17 && inChi.Minute() == 0
got := schedule.IsFriday1700Central(tt)
if want != got {
mismatches++
if firstMismatch == "" {
firstMismatch = tt.Format(time.RFC3339) + " (Central " + inChi.Format(time.RFC3339) + ")"
}
}
}
if mismatches != 0 {
t.Errorf("hand-rolled gate disagreed with America/Chicago at %d instants; first: %s",
mismatches, firstMismatch)
}
}
// --- Run orchestration tests (mock lifecycle + mock publisher) ---
// fakeLister is a mock PendingLister recording call count and returning canned ids/err.
type fakeLister struct {
ids []string
err error
calls int
}
func (f *fakeLister) ListPending(context.Context) ([]string, error) {
f.calls++
return f.ids, f.err
}
// recordingPublisher captures the id batches handed to it.
type recordingPublisher struct {
batches [][]string
err error
}
func (p *recordingPublisher) Publish(_ context.Context, ids []string) error {
p.batches = append(p.batches, ids)
return p.err
}
// A Friday 17:00 Central instant (CDT), reused by the Run tests.
var fireInstant = utc(2026, time.July, 10, 22, 0)
// notFireInstant is the sibling UTC cron hour on the same Friday: 18:00 Central.
var notFireInstant = utc(2026, time.July, 10, 23, 0)
// TestRunPublishesPendingWhenGateFires is the ticket's second acceptance for the
// handler: when it is Friday 17:00 Central, Run lists pending and hands the
// publisher exactly those ids, once.
func TestRunPublishesPendingWhenGateFires(t *testing.T) {
want := []string{"20260703T120000-aaaa", "20260704T090000-bbbb", "20260705T221530-cccc"}
lister := &fakeLister{ids: want}
pub := &recordingPublisher{}
ran, err := schedule.Run(context.Background(), fireInstant, lister, pub)
if err != nil {
t.Fatalf("Run: %v", err)
}
if !ran {
t.Fatalf("Run ran=false at Friday 17:00 Central, want true")
}
if lister.calls != 1 {
t.Errorf("ListPending called %d times, want 1", lister.calls)
}
if len(pub.batches) != 1 {
t.Fatalf("Publish called %d times, want 1", len(pub.batches))
}
if !slices.Equal(pub.batches[0], want) {
t.Errorf("Publish got ids %v, want exactly the pending ids %v", pub.batches[0], want)
}
}
// TestRunSkipsWhenGateClosed proves the sibling cron fire (18:00 Central) does no
// work: neither the lister nor the publisher is touched. Combined with the
// gate-fires test above, this is what makes publishing happen exactly once per
// Friday from two UTC crons.
func TestRunSkipsWhenGateClosed(t *testing.T) {
lister := &fakeLister{ids: []string{"should-not-be-listed"}}
pub := &recordingPublisher{}
ran, err := schedule.Run(context.Background(), notFireInstant, lister, pub)
if err != nil {
t.Fatalf("Run: %v", err)
}
if ran {
t.Errorf("Run ran=true at the sibling cron hour (18:00 Central), want false")
}
if lister.calls != 0 {
t.Errorf("ListPending called %d times on a skipped fire, want 0", lister.calls)
}
if len(pub.batches) != 0 {
t.Errorf("Publish called on a skipped fire, want not called")
}
}
// TestRunPublishesEmptyBatch checks that a Friday with no pending reports still
// invokes the publisher (with an empty slice), so #14 sees every scheduled run.
func TestRunPublishesEmptyBatch(t *testing.T) {
lister := &fakeLister{ids: nil}
pub := &recordingPublisher{}
ran, err := schedule.Run(context.Background(), fireInstant, lister, pub)
if err != nil {
t.Fatalf("Run: %v", err)
}
if !ran {
t.Errorf("ran=false, want true")
}
if len(pub.batches) != 1 {
t.Fatalf("Publish called %d times, want 1 (even for an empty batch)", len(pub.batches))
}
if len(pub.batches[0]) != 0 {
t.Errorf("Publish got %v, want empty batch", pub.batches[0])
}
}
// TestRunPropagatesListerError surfaces a lifecycle failure and does not publish.
func TestRunPropagatesListerError(t *testing.T) {
sentinel := errors.New("r2 list failed")
lister := &fakeLister{err: sentinel}
pub := &recordingPublisher{}
ran, err := schedule.Run(context.Background(), fireInstant, lister, pub)
if !ran {
t.Errorf("ran=false, want true (the gate fired before the error)")
}
if !errors.Is(err, sentinel) {
t.Errorf("Run err = %v, want it to wrap %v", err, sentinel)
}
if len(pub.batches) != 0 {
t.Errorf("Publish called despite a list error, want not called")
}
}
// TestRunPropagatesPublisherError surfaces a publish failure to the caller (the
// Worker), which lets the runtime record the scheduled invocation as failed.
func TestRunPropagatesPublisherError(t *testing.T) {
sentinel := errors.New("publish failed")
lister := &fakeLister{ids: []string{"id-1"}}
pub := &recordingPublisher{err: sentinel}
ran, err := schedule.Run(context.Background(), fireInstant, lister, pub)
if !ran {
t.Errorf("ran=false, want true")
}
if !errors.Is(err, sentinel) {
t.Errorf("Run err = %v, want it to wrap %v", err, sentinel)
}
}
// TestLogPublisherDefault covers the no-op default seam wired until #14: it
// reports the batch it was handed and never errors.
func TestLogPublisherDefault(t *testing.T) {
var logged string
pub := schedule.LogPublisher{Logf: func(format string, args ...any) {
logged = format
}}
if err := pub.Publish(context.Background(), []string{"a", "b"}); err != nil {
t.Fatalf("LogPublisher.Publish: %v", err)
}
if logged == "" {
t.Error("LogPublisher did not log anything")
}
}
func mustLoadChicago(t *testing.T) *time.Location {
t.Helper()
loc, err := time.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("LoadLocation(America/Chicago): %v (time/tzdata should make this always succeed)", err)
}
return loc
}
+71
View File
@@ -0,0 +1,71 @@
//go:build js && wasm
// This file wires the weekly publish Cron Trigger (#13) into the Cloudflare
// Worker. It is deliberately separate from worker/main.go: the scheduled task is
// registered here from init() via cron.ScheduleTaskNonBlock, which only records a
// package-level callback and returns. init() runs before main(), so by the time
// main()'s workers.Serve signals the runtime ready both the fetch handler and
// this scheduled handler are registered — and worker/main.go needs no change
// (keeping this ticket's diff off the shared fetch wiring).
//
// Compiled only into the js/wasm Worker; excluded from host builds and tests. All
// non-trivial logic — the DST timezone gate and the list→publish orchestration —
// lives in internal/schedule, which is build-tag-free and host-tested; this file
// is the thin runtime adapter that binds the R2-backed lifecycle Manager to it.
package main
import (
"context"
"log"
"time"
"github.com/syumai/workers/cloudflare/cron"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/schedule"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
// init registers the Cron Trigger handler with the syumai/workers runtime. The
// cron package's own init installs the JS "runScheduler" binding; this call just
// selects which Task that binding runs.
func init() {
cron.ScheduleTaskNonBlock(runWeeklyTrigger)
}
// runWeeklyTrigger is invoked by the Workers runtime on every Cron Trigger fire.
//
// Cloudflare Cron Triggers are UTC-only, so wrangler.jsonc schedules BOTH Friday
// UTC hours that can be 17:00 America/Chicago — 22:00 UTC during CDT (summer) and
// 23:00 UTC during CST (winter) — and this handler defers the timezone decision
// to schedule.Run. Run gates on the event's scheduled time so only the fire that
// is actually 17:00 Central lists the pending reports (via the R2-backed
// lifecycle Manager, #10) and hands their ids to the publish step; the sibling
// fire is a no-op. Net effect: publishing runs exactly once each Friday, correct
// across the DST boundary, from two static UTC crons.
func runWeeklyTrigger(ctx context.Context) error {
event, err := cron.NewEvent(ctx)
if err != nil {
return err
}
// Resolve the R2 bucket binding (synchronous, no I/O). It goes unused on the
// sibling fire, where Run's gate is closed; that happens at most once a week.
store, err := storage.NewR2Store(storage.BucketBinding)
if err != nil {
return err
}
manager := lifecycle.New(store)
// schedule.LogPublisher is the no-op default seam; the publish job (#14) will
// replace it with the real GitHub-issue publisher without touching this file.
ran, err := schedule.Run(ctx, event.ScheduledTime, manager, schedule.LogPublisher{})
if err != nil {
return err
}
if !ran {
log.Printf("schedule: cron %q fired for %s, not Friday 17:00 America/Chicago; the sibling cron covers this week",
event.Cron, event.ScheduledTime.Format(time.RFC3339))
}
return nil
}
+18 -1
View File
@@ -41,5 +41,22 @@
"store_id": "<store-id>",
"secret_name": "bugreport-enc-keyring"
}
]
],
// Cron Triggers for the weekly publish job (#13): "Friday 17:00 America/Chicago
// (Central), DST-correct". Cloudflare evaluates crons in UTC only and has no
// timezone support, and 17:00 Central is a different UTC hour depending on DST:
// 22:00 UTC during Central Daylight Time (CDT, UTC-5, summer) and 23:00 UTC
// during Central Standard Time (CST, UTC-6, winter). A single UTC cron therefore
// cannot express it, so we register BOTH candidate Friday UTC hours below. The
// Worker's scheduled handler (worker/scheduled_wasm.go -> internal/schedule)
// gates each fire on the real America/Chicago rule, so exactly one of the two
// does the publish work on any given Friday and the other is a no-op. This makes
// the trigger fire at 17:00 Central year-round with no manual DST maintenance.
"triggers": {
"crons": [
"0 22 * * 5", // Fridays 22:00 UTC == 17:00 Central during CDT (summer)
"0 23 * * 5" // Fridays 23:00 UTC == 17:00 Central during CST (winter)
]
}
}