Post

When Timezones Break Your CI: Battling BST vs UTC with Ruby

When Timezones Break Your CI: Battling BST vs UTC with Ruby

Twice a year, engineers across the UK encounter mysterious failing tests, flaky job schedules, or even missed cron jobs. The culprit? British Summer Time (BST) — that sneaky daylight saving switch that causes unexpected behavior when systems assume UTC or rely on local time inconsistently.

In this article, we’ll explore how BST can cause subtle bugs in your Ruby apps, CI pipelines, cloud functions — and how to defend against them with practical strategies and real-world examples.


💥 The Problem

Let’s say you have a scheduled job that runs daily at 2:00am BST. In the UK, the clock moves forward an hour in March (from GMT to BST), and back in October.

Now imagine this code:

1
2
3
4
5
6
7
8
9
# schedule_job.rb
require 'time'

def schedule_job
  now = Time.now
  puts "Scheduling job at: #{now}"
end

schedule_job

If your system is set to a UK local timezone, this will return BST during summer and GMT during winter. But:

  • In CI, your runners might be set to UTC.
  • In Cloud Functions, schedules might trigger in UTC or require explicit timezone config.
  • In code, comparing Time.now vs Time.utc or Time.parse("2024-04-01 02:00:00") can yield surprising results.

💪 CI Timezone Pitfall

Say you have this test:

1
2
3
4
5
6
describe "ScheduledJob" do
  it "schedules at 2 AM" do
    scheduled_time = Time.parse("2024-04-01 02:00:00")
    expect(scheduled_time.hour).to eq(2)
  end
end

This might pass locally but fail in CI if:

  • CI is running in UTC
  • Your code implicitly assumes local time

Instead, use:

1
2
3
4
Time.use_zone("London") do
  scheduled_time = Time.zone.parse("2024-04-01 02:00")
  expect(scheduled_time.hour).to eq(2)
end

Always specify time zones in tests.


⏰ Cloud Scheduler: UTC by Default

If you’re using something like Google Cloud Scheduler or AWS EventBridge, their schedules are in UTC by default. You might define a function to run at "0 2 * * *" thinking it’s 2 AM BST, but it’ll run at 2 AM UTC — which is 3 AM BST in summer.

Mitigation:

  • Always define schedules in UTC unless your service explicitly supports timezones.
  • In Ruby, convert like so:
1
2
3
4
5
Time.use_zone("London") do
  local_time = Time.zone.local(2024, 4, 1, 2)
  utc_time = local_time.utc
  puts "2am BST is #{utc_time} UTC"
end

⚖️ The Real-World Dilemma: Business Logic vs System Logic

Here’s the tricky part: sometimes the business logic wants 11 PM London time, regardless of BST or GMT — but sometimes it wants a fixed point in absolute time (UTC). You must define the intent before you write the code.

Example: Student Assignment Deadline

“The submission deadline is 11:00 PM London time on April 1st.”

Case 1: Business Logic Wants Local Time

If students (especially UK-based) expect the deadline to always be 11 PM London time (even if the clock shifts for DST), your code needs to store the timezone-aware local time:

1
2
3
4
Time.use_zone("London") do
  deadline = Time.zone.local(2024, 4, 1, 23, 0) # 11 PM London time
  puts "Deadline in UTC: #{deadline.utc}"
end

Case 2: Business Logic Wants a Fixed Instant

If the business says “we want this exact deadline to always occur at 22:00 UTC”, you should not convert it based on timezone — you store and evaluate it in UTC.

1
deadline = Time.utc(2024, 4, 1, 22, 0) # fixed instant in UTC

International Users: Timezones Matter Even More

If your app has users in other timezones (e.g., international students), make sure to:

  • Store all times in UTC
  • Present times in the user’s local timezone via front-end or email
  • Be explicit in messages (e.g., “Deadline is 11 PM London time (BST)”)

🔮 Testing DST Edge Cases in Ruby

Daylight Saving Time introduces two tricky transitions every year:

  • Spring forward (e.g., March): 2:00 AM → 3:00 AM (the hour from 2:00 to 2:59 doesn’t exist)
  • Fall back (e.g., October): 2:00 AM → 1:00 AM (the hour from 1:00 to 1:59 happens twice)

📊 Example 1: Spring Forward – Nonexistent Time

1
2
3
4
Time.use_zone("London") do
  nonexistent = Time.zone.parse("2024-03-31 02:30")
  puts nonexistent # => Sun, 31 Mar 2024 01:30:00 GMT +00:00 (rolled back silently!)
end

✅ Mitigation:

1
2
3
4
5
6
7
8
Time.use_zone("London") do
  begin
    ts = Time.zone.local(2024, 3, 31, 2, 30)
    puts ts
  rescue => e
    puts "Invalid time: #{e.message}"
  end
end

📊 Example 2: Fall Back – Ambiguous Time

1
2
3
4
Time.use_zone("London") do
  ambiguous = Time.zone.parse("2024-10-27 01:30")
  puts ambiguous # => Which one? Pre-DST or post-DST?
end

✅ Mitigation:

1
2
3
4
5
6
zone = ActiveSupport::TimeZone["London"]
first_1_30 = zone.local(2024, 10, 27, 1, 30, 0, true)  # BST
second_1_30 = zone.local(2024, 10, 27, 1, 30, 0, false) # GMT

puts first_1_30  # => 01:30 BST
puts second_1_30 # => 01:30 GMT

✅ How to Test for DST in Your App

  • Unit test time-sensitive behavior with Timecop or travel_to:
1
2
3
travel_to Time.find_zone("London").local(2024, 3, 31, 2, 30) do
  # Your test code here
end
  • Validate and sanitize user input during DST transitions
  • Log and monitor DST-transition hours for errors or anomalies
  • Make sure cron/CI/test runners are aware of the local time vs UTC context

🗓️ Hard-Coded vs Dynamic DateTimes in Specs

In many Ruby projects, developers simplify their specs using hard-coded Time.parse(...) or string values, like:

1
2
3
it "sets the deadline correctly" do
  expect(my_deadline).to eq(Time.parse("2024-04-01 23:00:00"))
end

This can make specs fast and repeatable — but when daylight saving changes (BST/GMT), these tests might:

  • Break unexpectedly during certain months (usually March and October)
  • Pass locally but fail in CI, or vice versa
  • Misrepresent business logic if the expected time is meant to reflect a local “wall clock time”

✅ Pros of Hard-Coded Datetimes

  • Deterministic: Always the same regardless of current date/time
  • Easy to write and reason about
  • Stable snapshots for known conditions

❌ Cons of Hard-Coded Datetimes

  • BST/GMT ambiguity if you don’t specify a time zone
  • Doesn’t simulate real-world behavior, especially during DST changes
  • May silently shift meaning, depending on system time zone
  • Risk of becoming stale over time (e.g., “2023” test dates break in 2025 logic)

✅ Pros of Dynamic Datetimes (e.g. Time.zone.today, 1.day.from_now)

  • Simulates real usage patterns
  • Easier to test edge cases dynamically (e.g., “next Sunday at 1:30 AM”)
  • Avoids hard-coded, out-of-sync values

❌ Cons of Dynamic Datetimes

  • Tests may fail as time passes (e.g., “today” means different things tomorrow)
  • More moving parts, harder to debug
  • DST shifts might be missed unless specifically tested

🧠 Best Practice: Combine Both Intentionally

  1. ✅ Use hard-coded datetime values with time zone awareness for stable tests:
1
2
3
4
Time.use_zone("London") do
  time = Time.zone.parse("2024-10-27 01:30")
  expect(job.run_at).to eq(time)
end
  1. ✅ Use dynamic time only when testing relative behavior:
1
2
3
4
5
it "sends reminder 1 day before deadline" do
  deadline = 3.days.from_now
  reminder = 1.day.before(deadline)
  expect(send_reminder_at).to eq(reminder)
end
  1. ✅ For BST-related cases, write explicit DST edge case tests with fixed problematic times:
1
2
3
4
5
6
describe "DST transition" do
  it "handles missing hour during spring forward" do
    time = Time.find_zone("London").local(2024, 3, 31, 2, 30)
    expect(time).not_to eq(nil) # or test fallback behavior
  end
end

🧰 Summary

Timezones — especially when BST kicks in — can break your code, tests, and jobs in subtle ways. Even more, the difference between what the business wants and what your system stores can lead to nasty bugs.

By clarifying your intent, storing time correctly, and displaying time responsibly, you can avoid the twice-yearly timezone trap — and keep your system running like clockwork.

This post is licensed under CC BY 4.0 by the author.