Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/front-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ on: # trigger configuration (unified under on: key)
paths:
include: ["src/**"]
exclude: ["docs/**"]
schedule: daily around 14:00 # fuzzy schedule - see docs/schedule-syntax.md
schedule: daily on weekdays # fuzzy, raw cron, or list - see docs/schedule-syntax.md
pipeline:
name: "Build Pipeline" # source pipeline name
project: "OtherProject" # optional: project name if different
Expand Down
58 changes: 56 additions & 2 deletions docs/schedule-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ _Part of the [ado-aw documentation](../AGENTS.md)._

## Schedule Syntax (Fuzzy Schedule Time Syntax)

The `on.schedule` field supports a human-friendly fuzzy schedule syntax that automatically distributes execution times to prevent server load spikes. The syntax is based on the [Fuzzy Schedule Time Syntax Specification](https://github.com/githubnext/gh-aw/blob/main/docs/src/content/docs/reference/fuzzy-schedule-specification.md).
The `on.schedule` field supports a human-friendly fuzzy schedule syntax that automatically distributes execution times to prevent server load spikes. The syntax follows the [gh-aw schedule reference](https://github.github.com/gh-aw/reference/schedule-syntax/) and its fuzzy-schedule parser where the concepts map to Azure Pipelines. The [formal fuzzy schedule specification](https://github.com/githubnext/gh-aw/blob/main/docs/src/content/docs/specs/fuzzy-schedule-specification.md) describes the core grammar but currently trails the reference for weekday modifiers.

Schedule is configured under the `on:` key:

Expand All @@ -22,8 +22,15 @@ schedule: daily around 3pm # 12-hour format supported
schedule: daily around midnight # Keywords: midnight, noon
schedule: daily between 9:00 and 17:00 # Business hours (9 AM - 5 PM)
schedule: daily between 22:00 and 02:00 # Overnight (handles midnight crossing)
schedule: daily on weekdays # Monday-Friday at a scattered time
schedule: daily around 9am on weekdays # Monday-Friday within ±60 minutes
schedule: daily between 9:00 and 17:00 on weekdays
```

`on weekdays` is supported on daily schedules, including `around` and
`between`. The generated Azure Pipelines cron uses the day-of-week range
`1-5`.

### Weekly Schedules

```yaml
Expand All @@ -44,10 +51,15 @@ schedule: every 1h # Equivalent to hourly
schedule: every 2h # Every 2 hours at scattered minute
schedule: every 2 hours # Long form also supported
schedule: every 6h # Every 6 hours at scattered minute
schedule: hourly on weekdays
schedule: every 2h on weekdays
```

Valid hour intervals: 1, 2, 3, 4, 6, 8, 12 (factors of 24 for even distribution)

Weekday filtering is not supported for minute, day, week, bi-weekly, or
tri-weekly intervals.

### Minute Intervals (Fixed, Not Scattered)

```yaml
Expand Down Expand Up @@ -84,9 +96,20 @@ schedule: daily around 14:00 utc+9 # 2 PM JST → 5 AM UTC
schedule: daily around 3pm utc-5 # 3 PM EST → 8 PM UTC
schedule: daily around 09:00 utc # Bare UTC means UTC+0
schedule: daily between 9am utc+05:30 and 5pm utc+05:30 # IST business hours
schedule: daily around 08:00 utc-7 on weekdays
```

Supported offset formats: `utc`, `utc+9`, `utc-5`, `utc+05:30`, `utc-08:00`
Supported offset formats: `utc`, `utc+9`, `utc-5`, `utc+05:30`, `utc-08:00`.
Keep the offset with the time and put `on weekdays` last. For compatibility
with the syntax requested in #1965, `daily around 08:00 on weekdays utc-7` is
also accepted.

Azure Pipelines YAML schedules are always evaluated in UTC. IANA timezone names
such as `America/New_York` are not supported because Azure Pipelines has no
schedule timezone field and a single UTC cron cannot preserve local wall-clock
time across daylight-saving transitions. When a fixed UTC offset crosses
midnight, the compiler rotates the cron weekday range so the schedule still
runs Monday-Friday in the requested local offset.

### How Scattering Works

Expand All @@ -112,3 +135,34 @@ schedule:
- main
- release/*
```

### Raw Cron and Multiple Schedules

Use a validated five-field Azure Pipelines cron expression when fuzzy syntax
cannot express the required schedule:

```yaml
on:
schedule: "0 9 * * 1-5"
```

The fields are `minute hour day-of-month month day-of-week`. Numeric values,
wildcards, lists, ranges, and steps are supported and validated against the ADO
field ranges. Months and weekdays also accept full English names or their first
three letters, such as `Jan` and `Mon-Fri`.

Use list form to configure multiple fuzzy and/or raw cron schedules. Each item
may specify its own branch include list; omitted branches default to `main`.

```yaml
on:
schedule:
- cron: daily on weekdays
- cron: "0 9 * * 1-5"
branches:
- main
- release/*
```

List items accept only `cron` and `branches`. IANA `timezone`, display-name,
batching, and branch-exclusion options are not supported.
70 changes: 55 additions & 15 deletions src/compile/agentic_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,21 +984,28 @@ fn build_resources(repos: &[RepoCfg], on: &Option<OnConfig>) -> Result<Resources
fn build_triggers(on: &Option<OnConfig>, front_matter: &FrontMatter) -> Result<Triggers> {
// Schedules — fuzzy schedule parsed once into typed Schedule items.
let mut schedules: Vec<Schedule> = Vec::new();
if let Some(s) = front_matter.schedule() {
let parsed = crate::fuzzy_schedule::parse_fuzzy_schedule(s.expression())?;
let cron = crate::fuzzy_schedule::generate_cron(&parsed, &front_matter.name);
let branches = s.branches();
let branches_include = if branches.is_empty() {
vec!["main".to_string()]
} else {
branches.to_vec()
};
schedules.push(Schedule {
cron,
display_name: "Scheduled run".to_string(),
branches_include,
always: true,
});
if let Some(config) = front_matter.schedule() {
let entries = config.entries();
if entries.is_empty() {
anyhow::bail!("on.schedule list must contain at least one schedule item");
}
for schedule in entries {
let cron = crate::fuzzy_schedule::schedule_expression_to_cron(
schedule.expression,
&front_matter.name,
)?;
let branches_include = if schedule.branches.is_empty() {
vec!["main".to_string()]
} else {
schedule.branches.to_vec()
};
schedules.push(Schedule {
cron,
display_name: "Scheduled run".to_string(),
branches_include,
always: true,
});
}
}

// `on:` declares when this pipeline runs, and both keys are ALWAYS
Expand Down Expand Up @@ -8119,6 +8126,39 @@ safe-outputs:
assert_eq!(t.schedules.len(), 1);
}

#[test]
fn build_triggers_compiles_mixed_schedule_list() {
let t = triggers_for(&format!(
"{BASE}on:\n schedule:\n - cron: daily on weekdays\n - cron: '0 9 * * 1-5'\n branches: [release/*]\n"
));
assert_eq!(t.schedules.len(), 2);
assert_eq!(
t.schedules[0]
.cron
.split_whitespace()
.last()
.expect("cron should have a day-of-week field"),
"1-5"
);
assert_eq!(t.schedules[0].branches_include, vec!["main".to_string()]);
assert_eq!(t.schedules[1].cron, "0 9 * * 1-5");
assert_eq!(
t.schedules[1].branches_include,
vec!["release/*".to_string()]
);
}

#[test]
fn build_triggers_rejects_empty_schedule_list() {
let fm = test_front_matter(&format!("{BASE}on:\n schedule: []\n"));
let error = build_triggers(&fm.on_config, &fm).unwrap_err();
assert!(
error
.to_string()
.contains("must contain at least one schedule item")
);
}

#[test]
fn build_triggers_explicit_push_wins_over_schedule() {
// "Run nightly, and also whenever `main` moves" is a legitimate
Expand Down
17 changes: 10 additions & 7 deletions src/compile/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1317,8 +1317,9 @@ Body
"#;
let (fm, _) = parse_markdown(content).unwrap();
let schedule = fm.schedule().unwrap();
assert_eq!(schedule.expression(), "daily around 14:00");
assert!(schedule.branches().is_empty());
let entries = schedule.entries();
assert_eq!(entries[0].expression, "daily around 14:00");
assert!(entries[0].branches.is_empty());
}

#[test]
Expand All @@ -1337,13 +1338,14 @@ Body
"#;
let (fm, _) = parse_markdown(content).unwrap();
let schedule = fm.schedule().unwrap();
assert_eq!(schedule.expression(), "weekly on friday around 17:00");
assert_eq!(schedule.branches(), &["main", "release/*"]);
let entries = schedule.entries();
assert_eq!(entries[0].expression, "weekly on friday around 17:00");
assert_eq!(entries[0].branches, &["main", "release/*"]);
}

#[test]
fn test_schedule_object_form_no_branches() {
// Object form without a `branches` key: schedule.branches() must default to empty.
// Object form without a `branches` key defaults to an empty branch list.
let content = r#"---
name: "Agent"
description: "Test"
Expand All @@ -1355,8 +1357,9 @@ Body
"#;
let (fm, _) = parse_markdown(content).unwrap();
let schedule = fm.schedule().unwrap();
assert_eq!(schedule.expression(), "daily around 10:00");
assert!(schedule.branches().is_empty());
let entries = schedule.entries();
assert_eq!(entries[0].expression, "daily around 10:00");
assert!(entries[0].branches.is_empty());
}

#[test]
Expand Down
8 changes: 5 additions & 3 deletions src/compile/pr_filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,8 +411,9 @@ on:
let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let oc: OnConfig = serde_yaml::from_value(val["on"].clone()).unwrap();
assert!(oc.schedule.is_some(), "should have schedule");
let schedule = oc.schedule.as_ref().unwrap().entries();
assert_eq!(
oc.schedule.unwrap().expression(),
schedule[0].expression,
"daily around 14:00",
"schedule expression should round-trip"
);
Expand Down Expand Up @@ -447,9 +448,10 @@ on:
let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let oc: OnConfig = serde_yaml::from_value(val["on"].clone()).unwrap();
let schedule = oc.schedule.unwrap();
assert_eq!(schedule.expression(), "weekly on monday");
let entries = schedule.entries();
assert_eq!(entries[0].expression, "weekly on monday");
assert_eq!(
schedule.branches(),
entries[0].branches,
&["main"],
"schedule branches should round-trip"
);
Expand Down
Loading
Loading