Scheduled pipelines

triggers.schedule (inside cicd({ pipeline: { triggers } })) declares cron schedules in code, each in an optional local IANA timezone. GitHub Actions and Azure Pipelines only understand UTC cron, so Zuke compiles a { cron, tz } entry into the UTC cron(s) that fire at the intended local wall-clock time.

Concept

A daylight-saving zone uses two UTC offsets across the year, so the intended local time maps to two UTC crons (one per offset). Zuke registers both and generates a wall-clock guard so only the firing at the currently-correct offset proceeds. A fixed-offset zone (or plain UTC / an omitted tz) needs a single cron and no guard.

Offsets are sampled from a pinned reference year (2024), so the generated crons never change as the calendar advances — the same input always produces the same output.

ScheduleEntry API

Each entry is a plain 5-field cron plus an optional IANA zone:

/** A scheduled trigger: a 5-field cron expression in an optional IANA timezone. */
export interface ScheduleEntry {
  cron: string;   // standard 5-field: minute hour day-of-month month day-of-week
  tz?: string;    // IANA zone (e.g. "Europe/Sofia"); omitted/"UTC" = already UTC, emitted verbatim
}
// on CiTriggers:  schedule?: ScheduleEntry[];
// lives at spec.pipeline.triggers.schedule inside cicd(spec)
export function cicd(spec: CiFileSpec): CiFile
export interface CiFileSpec {
  provider: CiProvider;            // "github" | "gitlab" | "azure" | "bitbucket"
  path?: string;
  pipeline?: CiPipeline;           // { name?, triggers?, permissions?, concurrency?, jobs? }
  fanOut?: boolean | FanOutOptions;
}
// helpers: utcCronsFor(entry): string[]; scheduleNeedsGuard(entry): boolean;
//          anyScheduleNeedsGuard(schedule): boolean; guardShell(schedule): string

The canonical example — weekday mornings and afternoons, Sofia local time:

ci = cicd({
  provider: "github",
  pipeline: {
    triggers: {
      push: ["main"],
      // Weekday mornings and afternoons, Sofia local time.
      schedule: [{ cron: "30 9,13,15 * * 1-4", tz: "Europe/Sofia" }],
    },
  },
});

Per-provider compilation

ProviderBehavior
GitHub (full) on.schedule is the de-duplicated union of utcCronsFor(entry) across all entries. The Sofia example compiles to two UTC crons — EET (UTC+2): 30 7,11,13 * * 1-4, EEST (UTC+3): 30 6,10,12 * * 1-4. If any entry needs a guard, Zuke emits a zuke-schedule-guard job (runs on ubuntu-latest, outputs run) that reads TZ='<zone>' date and compares the wall-clock; every other job gets needs: [..., zuke-schedule-guard] and an if: ANDing needs.zuke-schedule-guard.outputs.run == 'true'. On non-schedule events the guard sets run=true (push/PR/manual always proceed); on a scheduled event only the firing at the correct offset proceeds.
Azure Native schedules: for UTC/fixed-offset only — { cron, branches: { include: [...] }, always: true }. A DST-zone entry throws (the guard is GitHub-only).
GitLab & Bitbucket Documented limitation — the schedule trigger is ignored. Schedules are configured in the provider web UI, not in the generated file.

A DST zone registers two UTC crons, so it "fires" twice as often across the year on GitHub; the guard suppresses the off-offset firing and lets non-schedule events through unconditionally.

Cron grammar

Zuke supports a deliberate subset of cron syntax — five fields:

  • minute (0–59), hour (0–23), day-of-month (1–31), month (1–12), day-of-week (0–7)
  • each field: *, a single number, comma lists, ranges (a-b), or slash steps (*/n or a-b/n)
  • day-of-week 0 and 7 both mean Sunday
  • the timezone must have a whole-hour UTC offset

Errors

An unsupported schedule is reported before any file is generated, with a friendly message pointing at the fix — wrong field count, named fields/hashes/non-numeric values, bad steps, out-of-range values, an unknown timezone, a fractional-hour zone, or a day-crossing shift while a day field is constrained:

cicd: unsupported schedule — <reason>. Write the cron in UTC directly (drop the tz), or simplify it.

# Azure, DST-zone entry:
cicd: Azure Pipelines schedules are UTC-only and Zuke's daylight-saving guard is
GitHub-only. Use a fixed-offset timezone, or write the cron in UTC, for the azure provider.

Drift & determinism

tz omitted, "", or "UTC" emits the cron verbatim — single, no guard. Overlapping UTC crons across entries are collapsed via a Set. Because offsets are sampled from a pinned reference year, the output is deterministic:

zuke generate-ci --check   # fails on drift: "Run zuke generate-ci and commit the result."
zuke generate-ci          # running the build also auto-regenerates

zuke generate-ci --check verifies the on-disk file matches the rendered output and fails on drift; running the build also auto-regenerates the file. See Code-first CI/CD for the rest of the cicd() surface — providers, fan-out, and the portable step subset.