{"title":"Test Cron Schedules by Their Fire Times, Not Their Strings","excerpt":"Reviewing a five-field string cannot tell you when a job runs: the two day fields are combined with OR in cron but AND in systemd, the spring gap deletes a run and the autumn fold duplicates one, and Kubernetes then decides separately whether a matched run may start. This shows you how to compute fire times from a pinned base time using the parser your scheduler actually vendors — robfig/cron for CronJobs, systemd-analyze calendar --base-time for timers — and how startingDeadlineSeconds, concurrencyPolicy and the 100-missed-schedule ceiling interact. It ends with the evidence to keep: the cronjob-scheduled-timestamp annotation diffed against actual start times, so a run that never happened shows up as a gap.","canonicalUrl":"https://automationtester.in/blog/tools-reviews/verify-cron-schedules-time-zones-boundaries","category":{"name":"Tools & Reviews","slug":"tools-reviews"},"tags":["cron","kubernetes-cronjob","systemd-timers","time-zones","scheduled-jobs","boundary-testing"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-07T13:30:00.000Z","dateModified":"2026-09-07T11:52:04.718Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v2/verify-cron-schedules-time-zones-boundaries.webp","alt":"Dark technical illustration of two schedule timelines running edge to edge. The upper lane shows wall-clock fire times as a row of green ticks hanging from a rule; at one highlighted transition column a tick is missing, drawn as a dashed outline with a crossed-out circle above it, and at a second transition column a single slot carries two overlapping orange ticks ringed together. Curved droppers connect every wall-clock tick to the lower lane, where the same schedule in UTC is a perfectly even row of green ticks with no gap and no duplicate, the fold's two instants landing as separate ringed nodes. A strip of small cells along the bottom repeats the pattern, one cell empty and one doubled."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> A cron expression is not a schedule. It is a matching rule evaluated against a wall clock that skips an hour every spring and repeats one every autumn, in a field layout where the two day fields are combined with OR rather than AND, under a scheduler that decides separately whether a matched run is still allowed to start. Test the fire times, not the string. Compute them with the same library your scheduler uses, pin the clock to a known DST boundary, and assert on exact instants.</div>\n\n<p>A reconciliation job runs nightly at 02:30 in <code>America/New_York</code>. Over 2026 it fired 364 times, not 365. The missing night is 8 March, when 02:30 did not exist: the clock went from 01:59:59 EST straight to 03:00:00 EDT. Nothing failed. No alert fired, because nothing was watching for a run that was never scheduled. The day's ledger simply carried into the next run, and the discrepancy surfaced eleven weeks later in a finance query.</p>\n\n<p>The same year, the 01:30 job on 1 November ran twice — once at 05:30 UTC and once at 06:30 UTC — because 01:30 local occurred twice that morning. Both behaviours are documented, both are correct, and neither is what the person who wrote <code>30 2 * * *</code> had in mind.</p>\n\n<h2 id=\"day-fields-or\">The two day fields are OR, and only sometimes</h2>\n\n<p>Start with the rule that catches people before time zones ever enter the picture. The <code>crontab(5)</code> man page states it plainly: if both the day-of-month and day-of-week fields are restricted — that is, neither contains <code>*</code> — the command runs when <em>either</em> field matches. Its own example is <code>30 4 1,15 * 5</code>, which runs at 04:30 on the 1st and 15th of each month, <em>plus</em> every Friday.</p>\n\n<pre class=\"language-text\"><code class=\"language-text\"># ┌───────────── minute       (0 - 59)\n# │ ┌─────────── hour         (0 - 23)\n# │ │ ┌───────── day of month (1 - 31)\n# │ │ │ ┌─────── month        (1 - 12)\n# │ │ │ │ ┌───── day of week  (0 - 6 in Kubernetes; 0 - 7 in cronie, where 0 and 7 are both Sunday)\n# │ │ │ │ │\n  30 4 1,15 * 5     # NOT \"the 1st and 15th, if they are a Friday\"\n                    # It is  \"the 1st, the 15th, AND every Friday\"  — roughly 6 runs a month.</code></pre>\n\n<p>Kubernetes inherits this exactly, because the CronJob controller vendors <code>github.com/robfig/cron/v3</code>, whose <code>dayMatches</code> returns <code>domMatch &amp;&amp; dowMatch</code> when either field carries the star bit and <code>domMatch || dowMatch</code> otherwise. Same rule, same trap.</p>\n\n<p>What Kubernetes does <em>not</em> inherit is the day-of-week range. Cronie documents 0–7 with both 0 and 7 meaning Sunday. The vendored Go parser declares <code>dow = bounds{0, 6, ...}</code>, so a schedule that has run for years on a Linux host is rejected outright when it is lifted into a manifest:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">\"0 0 * * 7\"   ERROR: end of range (7) above maximum (6): 7\n\"0 0 * * 0\"   ok — Sun 2026-03-08 00:00 EST, Sun 2026-03-15 00:00 EDT, ...</code></pre>\n\n<p>Two other dialects break the rule in opposite directions, and knowing which one you are holding matters more than knowing the syntax:</p>\n\n<ul>\n<li><strong>systemd calendar events are AND, not OR.</strong> <code>systemd.time(7)</code> gives <code>Thu,Fri 2012-*-1,5 11:12:13</code> and glosses it as the first or fifth day of any month in 2012, \"but only if that day is a Thursday or Friday\". The weekday is a filter on the date, not an alternative to it.</li>\n<li><strong>Quartz refuses to answer the question.</strong> Its expressions take six or seven fields with <em>seconds first</em>, day-of-week is 1–7 with 1 = Sunday, and <code>?</code> means \"no specific value\" and is used when you need to specify one day field but not the other. So <code>0 9 * * 1</code> is Monday in cron, while the Quartz analogue <code>0 0 9 ? * 1</code> is Sunday.</li>\n</ul>\n\n<h2 id=\"assert-fire-times\">Assert on fire times, not on the string</h2>\n\n<p>Every one of those differences disappears if you stop reviewing the expression and start computing the instants it produces. The unit under test is the scheduler's own parser, at the version you deploy, evaluated from a fixed base time.</p>\n\n<p>For anything running on Kubernetes, that means the vendored library:</p>\n\n<pre class=\"language-go\"><code class=\"language-go\">// schedule_test.go — go test ./...\npackage schedule\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\tcron \"github.com/robfig/cron/v3\"\n)\n\n// Exactly the field set the CronJob controller parses: five fields plus macros.\nvar parser = cron.NewParser(\n\tcron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,\n)\n\nfunc fireTimes(t *testing.T, spec string, from time.Time, n int) []time.Time {\n\tt.Helper()\n\ts, err := parser.Parse(spec)\n\tif err != nil {\n\t\tt.Fatalf(\"parse %q: %v\", spec, err)\n\t}\n\tout := make([]time.Time, 0, n)\n\tfor cur := from; len(out) &lt; n; {\n\t\tcur = s.Next(cur)\n\t\tout = append(out, cur)\n\t}\n\treturn out\n}\n\nfunc TestNightlyReconcileSurvivesSpringForward(t *testing.T) {\n\tny, _ := time.LoadLocation(\"America/New_York\")\n\tfrom := time.Date(2026, 3, 7, 3, 0, 0, 0, ny) // just after the 7th has fired\n\n\t// robfig/cron accepts a TZ= prefix; .spec.schedule does NOT. In a manifest\n\t// this zone goes in .spec.timeZone instead. See the field notes below.\n\tgot := fireTimes(t, \"TZ=America/New_York 30 2 * * *\", from, 2)\n\n\t// This assertion FAILS, and that failure is the bug report.\n\twant := time.Date(2026, 3, 8, 2, 30, 0, 0, ny)\n\tif !got[0].Equal(want) {\n\t\tt.Fatalf(\"expected a run on 2026-03-08, next fire was %s\", got[0])\n\t}\n}</code></pre>\n\n<p>Run it and the library tells you precisely what it intends to do:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">--- next fires after 2026-03-07 03:00 from \"30 2 * * *\" in America/New_York\n    2026-03-09 02:30:00 EDT = 2026-03-09 06:30Z    # 8 March never appears\n--- next fires after 2026-10-31 03:00 from \"30 1 * * *\"\n    2026-11-01 01:30:00 EDT = 2026-11-01 05:30Z\n    2026-11-01 01:30:00 EST = 2026-11-01 06:30Z    # same wall clock, two instants\n    2026-11-02 01:30:00 EST = 2026-11-02 06:30Z</code></pre>\n\n<p>This is not library-specific misbehaviour. <code>crontab(5)</code> documents the identical outcome for classic cron: non-existent times \"will never match, causing jobs scheduled during the 'missing times' not to be run\", and times that occur more than once \"will cause matching jobs to be run twice\".</p>\n\n<p>For systemd, the equivalent test needs no code at all, because <code>systemd-analyze</code> exposes the calendar engine directly and <code>--base-time</code> makes the answer deterministic:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Does the timer really fire on the 29th of February, and when next?\nsystemd-analyze calendar --iterations=5 '*-2-29 0:0:0'\n\n# Pin the clock and ask for the next five elapses of a nightly job,\n# with the time zone written into the expression itself.\nsystemd-analyze calendar --iterations=5 \\\n  --base-time='2026-03-07 12:00:00' \\\n  'Mon..Sun *-*-* 02:30:00 America/New_York'\n\n# Normalisation is a linting tool: the tool prints the expression it understood.\nsystemd-analyze calendar 'weekly Pacific/Auckland'\n#   Original form: weekly Pacific/Auckland\n# Normalized form: Mon *-*-* 00:00:00 Pacific/Auckland</code></pre>\n\n<p>Reading the normalised form back is worth a habit of its own. It is the cheapest way to catch a schedule that parsed successfully and means something other than you intended.</p>\n\n<h2 id=\"three-boundary-shapes\">The three boundary shapes worth a test each</h2>\n\n<p>Daylight-saving failures are not one bug. They are three, with different symptoms and different fixes.</p>\n\n<p><strong>The gap: a run that never happens.</strong> Any wall-clock time inside the skipped hour has no instant, so it cannot match. In the United States in 2026 that hour is 02:00–02:59 on 8 March. Every schedule between <code>0 2 * * *</code> and <code>59 2 * * *</code> silently loses a day.</p>\n\n<p><strong>The fold: a run that happens twice.</strong> On 1 November 2026 the hour 01:00–01:59 occurs twice in <code>America/New_York</code>. An hourly job <code>0 * * * *</code> fires 25 times that day, with 01:00 EDT and 01:00 EST both matching. If the job is not idempotent, this is the expensive one.</p>\n\n<p><strong>Midnight itself can vanish.</strong> The habit of moving a job to <code>0 0 * * *</code> to dodge the 02:00 window works in North America and fails elsewhere. In <code>America/Havana</code> the spring transition happens at midnight, so 2026-03-08 has no 00:00 at all and a daily midnight job jumps 47 hours; on 1 November it fires twice. <code>America/Santiago</code> and <code>Asia/Beirut</code> transition at 00:00 too, in their own months.</p>\n\n<p>Half-hour offsets are the sharp edge here, because scheduler DST handling is frequently written for whole hours. The vendored Go library corrects a mis-landed day by adding or subtracting <em>one hour</em>, an assumption its own source comment makes explicit. Point it at <code>Australia/Lord_Howe</code>, which shifts by 30 minutes, and a plain midnight job skips a day that exists:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\"># spec \"0 0 * * *\", zone Australia/Lord_Howe, robfig/cron v3.0.1\n2026-04-05 00:00 +11    -&gt; 2026-04-07 00:00 +1030   delta 48h30m   # 04-06 skipped\n2026-10-04 00:00 +1030  -&gt; 2026-10-06 00:00 +11     delta 47h30m   # 10-05 skipped\n\n# ...yet the skipped midnight is a perfectly real instant:\n2026-04-06 00:00:00 +1030  -&gt;UTC  2026-04-05 13:30Z</code></pre>\n\n<p>The fix for all three is the same and it is boring: schedule in UTC and let the job decide what local day it is operating on.</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\"># Wrong: the schedule carries the business time zone, so the schedule\n# inherits that zone's discontinuities.\nspec:\n  schedule: \"30 2 * * *\"\n  timeZone: \"America/New_York\"\n\n# Right: the schedule is a monotonic UTC instant. The job resolves\n# \"yesterday in New York\" itself, where a test can cover the edge cases.\nspec:\n  schedule: \"30 7 * * *\"\n  timeZone: \"Etc/UTC\"</code></pre>\n\n<p>Kubernetes will not let you smuggle the zone in through the back door either: a <code>TZ</code> or <code>CRON_TZ</code> prefix inside <code>.spec.schedule</code> is not officially supported, and the API rejects the resource with a validation error. Use <code>.spec.timeZone</code>, stable since v1.27. With no zone set, the schedule is interpreted in <code>kube-controller-manager</code>'s local zone — a property of the control plane, not of your manifest, and not something your repository can review.</p>\n\n<h2 id=\"kubernetes-decides-separately\">Matching the schedule is not the same as running</h2>\n\n<p>Once the expression matches, three CronJob fields decide independently whether a Job object is actually created. Each has a distinct failure mode.</p>\n\n<p><code>.spec.startingDeadlineSeconds</code> is a deadline for a late start. The controller measures the gap between when a Job was expected and now; if the gap exceeds the limit, that execution is skipped, and Kubernetes treats it as a failed Job. Unset means no deadline. Below 10 seconds, the docs warn the CronJob may never be scheduled at all, because the controller only checks every 10 seconds.</p>\n\n<p><code>.spec.concurrencyPolicy</code> is <code>Allow</code> by default. <code>Forbid</code> skips the new run while the previous one is still going — and that skip <em>counts as a missed schedule</em>. <code>Replace</code> kills the running Job and starts a new one, which is the correct choice only for work that is safe to abandon mid-flight.</p>\n\n<p>Then the rule that turns a temporary outage into a permanent one. The controller counts how many schedules were missed between the last scheduled time and now. Past 100, it refuses to start the Job and logs <code>too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew</code>. The documentation's own arithmetic: a job scheduled every minute from 08:30, with no starting deadline, whose controller is down from 08:29 to 10:21, does not start — more than 100 schedules were missed. Set <code>startingDeadlineSeconds: 200</code> and the same outage ends with the job starting at 10:22, because the controller now counts misses only within the last 200 seconds.</p>\n\n<div class=\"callout callout-warning\"><strong>The combination to check in review:</strong> <code>concurrencyPolicy: Forbid</code> with <code>startingDeadlineSeconds</code> unset, on a frequent schedule whose runtime occasionally exceeds its interval. Every blocked run is a missed schedule; enough of them in a row and the catch-up path stops firing. The manifest looks conservative and is the least safe of the three configurations.</div>\n\n<p>Kubernetes is explicit that scheduling is approximate: a CronJob creates a Job \"approximately once per execution time of its schedule\", and there are circumstances where two Jobs are created or none is. The documented instruction is to make the Job idempotent. Treat that as a testing requirement — run the job twice against the same input in CI and assert the second run is a no-op — rather than as a caveat.</p>\n\n<h2 id=\"capture-the-evidence\">Evidence: capture intended time versus actual time</h2>\n\n<p>Since v1.32, every Job a CronJob creates carries the annotation <code>batch.kubernetes.io/cronjob-scheduled-timestamp</code>, holding the originally scheduled creation time in RFC3339. That is the missing half of every \"did it run on time\" question: the schedule's intent, recorded next to the observed reality.</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Intended fire time vs. when the Job actually started, per run.\nkubectl get jobs -l batch.kubernetes.io/cronjob-name=nightly-reconcile \\\n  -o jsonpath='{range .items[*]}{.metadata.annotations.batch\\.kubernetes\\.io/cronjob-scheduled-timestamp}{\"\\t\"}{.status.startTime}{\"\\n\"}{end}' \\\n  | sort\n\n# 2026-03-07T07:30:00Z    2026-03-07T07:30:04Z\n# 2026-03-09T06:30:00Z    2026-03-09T06:31:57Z   &lt;- 8 March absent entirely\n\n# The controller-side symptom of the 100-missed rule.\nkubectl get events --field-selector reason=FailedNeedsStart -A</code></pre>\n\n<p>Alert on the <em>absence</em> of an expected timestamp, not on job failure. A run that never started produces no failing pod, no non-zero exit and no log line. It produces a gap in this list, which is why the list has to exist before the gap does.</p>\n\n<h2 id=\"systemd-differences\">Where systemd timers behave differently</h2>\n\n<p>If you are choosing between mechanisms, three documented differences decide it.</p>\n\n<p><strong>Catch-up is opt-in and coalesced.</strong> <code>Persistent=true</code> stores the last trigger time on disk; when the timer activates, the service is triggered immediately if it would have fired at least once while the timer was inactive. Once — not once per missed elapse. Kubernetes has no equivalent switch; its catch-up behaviour is whatever <code>startingDeadlineSeconds</code> and the 100-miss ceiling produce.</p>\n\n<p><strong>Timers are deliberately imprecise by default.</strong> <code>AccuracySec=</code> defaults to one minute, and the timer fires at a host-specific, randomised but stable point inside that window, to let the manager coalesce wakeups. If your test asserts second-level precision, set <code>AccuracySec=1us</code> or the test is asserting something the unit never promised.</p>\n\n<p><strong>Jitter is a first-class field.</strong> <code>RandomizedDelaySec=</code> defaults to 0 and spreads firing over an interval; <code>FixedRandomDelay=</code> makes that delay deterministic per machine, so a fleet stays spread out but each host keeps a stable slot. Reproducing an equivalent for a CronJob means adding a sleep inside the container, which is jitter your monitoring cannot distinguish from lateness.</p>\n\n<h2 id=\"tzdata-moves\">The rules themselves change under you</h2>\n\n<p>A time zone is not a constant, and a test that hardcodes an offset is asserting a fact with an expiry date. The IANA database shipped three releases in 2025 and another three by July 2026. Release 2019b recorded that Brazil cancelled DST and would stay on standard time indefinitely — every schedule tuned around a São Paulo transition became wrong that year. Release 2026c, dated 8 July 2026, records Alberta moving to permanent −06 and Morocco moving to permanent UTC on 20 September 2026.</p>\n\n<p>Three consequences for your tests. Assert on IANA zone identifiers and computed instants, never on literal UTC offsets or abbreviations like <code>EST</code>. Pin the tzdata version your test image uses, so a base-image bump that changes a fire time fails a test rather than a payment run. And check where the data actually comes from: Kubernetes embeds the Go standard library's copy as a fallback when no external database is available on the system, so the control plane and your job container can disagree about a zone that changed recently.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Take your most business-critical scheduled job today and do three things. Compute its next twenty fire times from a base time of 1 March, using the parser its scheduler actually uses, and check that the day of the spring transition appears. Add one test that asserts the run count over the DST week — 7 for a daily job, 167 or 169 for an hourly one, never 168. Then run <code>kubectl get jobs</code> with the <code>cronjob-scheduled-timestamp</code> annotation and diff intended against actual for the last thirty runs.</p>\n\n<p>The evidence to keep is that intended-versus-actual list. It converts \"the job seems fine\" into a number, and it is the only artefact that shows a run which did not happen.</p>\n\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n\n<h3 id=\"faq-utc-everywhere\">If I schedule everything in UTC, is the problem gone?</h3>\n\n<p>The scheduling problem is. UTC has no gaps or folds, so a UTC schedule fires the same number of times every week forever. What moves is the business logic: a report due at 09:00 local now arrives an hour early or late for half the year. Either accept the drift, or have the job compute the local target itself and exit early when it is not yet due — a decision that lives in code you can unit test, rather than in a five-field string you cannot.</p>\n\n<h3 id=\"faq-catch-up\">Will Kubernetes catch up on runs missed during a node outage?</h3>\n\n<p>Up to a point. With <code>startingDeadlineSeconds</code> unset or large and <code>concurrencyPolicy: Allow</code>, the documentation says Jobs will always run at least once. But the controller stops starting the Job once it counts more than 100 missed schedules since the last scheduled time. Setting <code>startingDeadlineSeconds</code> changes the counting window to that many seconds back, which is how you avoid tripping the ceiling.</p>\n\n<h3 id=\"faq-question-mark\">Does <code>?</code> work in a Kubernetes schedule?</h3>\n\n<p>Yes, and it means something different from Quartz. Kubernetes documents <code>?</code> as having the same meaning as <code>*</code> — any available value for that field. In Quartz it means \"no specific value\" and exists so you can leave one of the two day fields unspecified. Copying a Quartz expression into a manifest also loses the leading seconds field and shifts every value one position left.</p>\n\n<h3 id=\"faq-test-dst-locally\">How do I test DST behaviour without waiting for March?</h3>\n\n<p>Do not manipulate the system clock. Pass an explicit base time to the scheduler's own calculator: <code>--base-time</code> for <code>systemd-analyze calendar</code>, or a fixed <code>time.Time</code> into <code>Schedule.Next</code> in Go. Both compute future elapses from any starting instant, which makes a DST test as fast and deterministic as any other unit test.</p>\n\n<h3 id=\"faq-double-run-cost\">The job is not idempotent and it ran twice. What do I fix first?</h3>\n\n<p>The job, not the schedule. Kubernetes states outright that two Jobs may be created for one execution time and that yours should therefore be idempotent, and the autumn fold produces the same duplicate on a plain Linux host. A schedule change removes one cause of a double run; a natural key or a scheduled-time-derived idempotency token removes all of them. The <code>cronjob-scheduled-timestamp</code> annotation is a ready-made token for exactly this.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://man7.org/linux/man-pages/man5/crontab.5.html\" target=\"_blank\" rel=\"noopener noreferrer\">crontab(5)</a>: the OR rule for the two day fields with the <code>30 4 1,15 * 5</code> example, day-of-week 0–7, and the statement that missing DST hours never match while repeated hours run jobs twice</li>\n<li><a href=\"https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/\" target=\"_blank\" rel=\"noopener noreferrer\">Kubernetes — CronJob</a>: schedule syntax, <code>?</code> as a synonym for <code>*</code>, <code>startingDeadlineSeconds</code>, the three <code>concurrencyPolicy</code> values, the 100-missed-schedule rule with the 08:29–10:21 worked example, <code>.spec.timeZone</code>, rejection of <code>TZ</code>/<code>CRON_TZ</code> in the schedule, approximate job creation, and the <code>batch.kubernetes.io/cronjob-scheduled-timestamp</code> annotation</li>\n<li><a href=\"https://github.com/robfig/cron\" target=\"_blank\" rel=\"noopener noreferrer\">robfig/cron</a>: the parser Kubernetes vendors — <code>dayMatches</code> implementing OR, <code>dow = bounds{0, 6}</code>, and the whole-hour DST correction in <code>SpecSchedule.Next</code></li>\n<li><a href=\"https://man7.org/linux/man-pages/man5/systemd.timer.5.html\" target=\"_blank\" rel=\"noopener noreferrer\">systemd.timer(5)</a>: <code>Persistent=</code> catch-up semantics, <code>AccuracySec=</code> defaulting to one minute, <code>RandomizedDelaySec=</code> defaulting to zero, and <code>FixedRandomDelay=</code></li>\n<li><a href=\"https://man7.org/linux/man-pages/man7/systemd.time.7.html\" target=\"_blank\" rel=\"noopener noreferrer\">systemd.time(7)</a>: calendar event syntax, the AND semantics of weekday plus date, and IANA time zone suffixes in an expression</li>\n<li><a href=\"https://man7.org/linux/man-pages/man1/systemd-analyze.1.html\" target=\"_blank\" rel=\"noopener noreferrer\">systemd-analyze(1)</a>: the <code>calendar</code> verb, <code>--iterations=</code>, and <code>--base-time=</code></li>\n<li><a href=\"https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/tutorial-lesson-06.html\" target=\"_blank\" rel=\"noopener noreferrer\">Quartz — CronTrigger tutorial</a>: seconds-first field order, day-of-week 1–7 with 1 = Sunday, and <code>?</code> as \"no specific value\"</li>\n<li><a href=\"https://data.iana.org/time-zones/tzdb/NEWS\" target=\"_blank\" rel=\"noopener noreferrer\">IANA time zone database — NEWS</a>: release 2019b cancelling Brazilian DST, release 2026c on Alberta and Morocco, and the release cadence</li>\n</ul>\n"}