日本語 · English

Cron: run on the last day of the month — and the last business day

Translated from the canonical Japanese page recipes/cron-last-day-of-month.md.

“Run at the end of every month” is one of cron’s most common stumbling blocks (325k views on Stack Overflow). In Kairos, both variants are one-line expressions:

monthEnd                                  # last day of the month
bizDay |> within(month) |> last           # last business day of the month

What happens with cron

The root cause is not a missing feature but that expressions do not compose — even if you can produce “month-end”, you cannot feed the result into the next rule (“roll backward onto business days”).

Writing it in Kairos

The holiday-aware last business day of the month, under the standard @JP premise (whose bizDay is derived from weekends plus Japan’s holiday data):

# eval: 2026-01-01..2026-07-01
@JP
bizDay |> within(month) |> last
#=> 2026-01-30 2026-02-27 2026-03-31 2026-04-30 2026-05-29 2026-06-30

January (1/31 Sat) and May (5/30 Sat, 5/31 Sun) correctly retreat to Friday. “Build the stream of business days, take the last point of each month” — the structure reads exactly as stated, the holidays come from calendar data (with covering:), and when that data runs out the result carries an annotation instead of silently degrading.

Counting back from month-end composes from the same vocabulary:

monthEnd |> roll(Preceding, on: bizDay) |> shift(-3, unit: bizDay)   # 3 business days before month-end

The same expression under a US calendar

The expression above does not depend on Japan’s holiday data in any way. Load the same expression onto the 2026 US federal holidays (observed dates):

# eval: 2026-01-01..2026-07-01 tz: America/New_York
premise US {
  calendar-system: Gregorian
  tz: "America/New_York"
  wkst: Sun
}

@US
federal2026 = [2026-01-01, 2026-01-19, 2026-02-16, 2026-05-25, 2026-06-19,
               2026-07-03, 2026-09-07, 2026-10-12, 2026-11-11, 2026-11-26,
               2026-12-25] covering: 2026..2026
satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
bizDay = everyDay \ (satSun | federal2026)

bizDay |> within(month) |> last
#=> 2026-01-30 2026-02-27 2026-03-31 2026-04-30 2026-05-29 2026-06-30

Only the premise — the data and the timezone — changed; the body expression is untouched, character for character. “Build the stream of business days, take the last point of each month” is a structure independent of any one country’s calendar — whichever calendar you run under, what you swap is the data. Run the US version in the Playground.

Running it — keep one crontab line

Kairos stops at when things should happen; firing, retrying and logging stay with your runner (systemd, a job queue, whatever you already trust — spec §7.8). Two wiring patterns, both working with the CLI as it ships (npm i -g kairos-lang).

Pattern 1: cron stays the clock, Kairos makes the decision. Keep a single crontab line and, every morning at 9, ask “is there a point today?”; run the job if so. Labels and the [--from, --to) window are read in the machine’s time zone (pass --tz if the definition’s premise zone differs):

0 9 * * *  cd /srv/batch && ./run-if-today.sh month-end.kairos ./close-books.sh
#!/bin/sh
# run-if-today.sh <definition.kairos> <job> — exec the job if there is a point in [today, tomorrow)
today=$(date +%F); tomorrow=$(date -d "$today + 1 day" +%F)   # GNU date; macOS: date -v+1d +%F
n=$(kairos list --from "$today" --to "$tomorrow" --json "$1" | jq '.results[0].dates | length')
[ "$n" -gt 0 ] && exec "$2"

Count with --json: the human-readable output also prints the coverage summary as # lines.

Pattern 2: schedule the next point, one at a time. Put the time of day into the definition as well (… |> last |> at(T23:55)) and ask next --json for the next firing. It comes back as wall-clock text (machine’s zone) and as epoch milliseconds; hand it to a one-shot OS timer and let the job re-register the next point as its last step — the “re-materialize periodically” loop the spec describes:

t=$(kairos next --json month-end.kairos | jq -r '.results[0].dates[0]')   # e.g. 2026-09-30T23:55
systemd-run --user --on-calendar="$(echo "$t" | tr T ' '):00" ./close-books.sh
# same shape with at(1) on Linux, or schtasks /sc once on Windows

Try it in your browser

A self-contained form (the holiday table inline) is ready to run in the Playground — move from/to around, add holidays, and watch the behavior.