Skip to main content

Why my Azure DevOps scheduled pipeline never ran

I set up a scheduled pipeline in Azure DevOps. The YAML was valid. No errors on save. I waited patiently for the cron to fire.

Nothing happened…

The culprit turned out to be a single line I'd added for a completely legitimate reason trigger: none.

The setup

The pipeline looked roughly like this:

trigger: none

schedules:
  - cron: "0 2 * * 1-5"
    displayName: Nightly weekday build
    branches:
      include:
        - main
    always: true

The intent was straightforward: I didn't want CI runs on every push, so I explicitly disabled that with trigger: none. And I wanted the pipeline to run on a schedule. Seems fine, right?

Except it never ran.

What's actually happening

Here's the thing that isn't obvious until you read the docs carefully (or waste an afternoon debugging): in Azure DevOps YAML pipelines, trigger is specifically the CI trigger — the thing that fires on code pushes. schedules is a completely separate concept.

So when I wrote trigger: none, I was trying to tell Azure DevOps: "don't fire on commits." That's it. I expected schedules to happily do its own thing in parallel.

But it didn’t.

When trigger: none is present, Azure DevOps suppresses scheduled runs as well. The schedule is defined, it validates fine, it even shows up if you look at the pipeline configuration — but the runs simply don't happen.

There's no warning. No error. No notification. Just silence.

The fix

Remove trigger: none. That's it.

If you don't want CI triggers firing on every push, but you do want scheduled runs, the correct approach is to either:

Option A — Let the default CI trigger stand, but scope it narrowly:

trigger:
  branches:
    include:
      - none  # effectively disables CI without using trigger: none

schedules:
  - cron: "0 2 * * 1-5"
    displayName: Nightly weekday build
    branches:
      include:
        - main
    always: true

Option B — Just remove trigger: entirely if this is a schedule-only pipeline:

schedules:
  - cron: "0 2 * * 1-5"
    displayName: Nightly weekday build
    branches:
      include:
        - main
    always: true

Without an explicit trigger: section, Azure DevOps applies the default CI behavior (trigger on all branches), unless your organization has the "Disable implied YAML CI trigger" setting enabled at org or project level. If you're worried about that, scope the CI trigger explicitly to a non-existent branch, or check your org settings.

Why I find this confusing?

Because the mental model I had is: "these are independent trigger types, and I can configure them independently." That's true in many CI systems. In Azure DevOps YAML, trigger: none is a broader hammer than it looks.

The official documentation does document this, but it's easy to miss if you're just looking to disable CI triggers and move on.

Other gotchas while we are here

If your scheduled pipeline still isn't running after removing trigger: none, check these:

UI-defined schedules override YAML schedules. If you ever configured a schedule through the pipeline settings UI, that takes precedence over everything you define in YAML. Only UI-defined schedules will run, and you'll need to delete them through the UI (then trigger a new push) before your YAML schedules take effect.

The schedule is evaluated per branch. A schedule only applies to branches explicitly listed in the branches.include section. If you're targeting main but your YAML changes are on a feature branch, the schedule for main is based on the last committed YAML on main. Changes on other branches don't update the schedule for main.

always: true matters. By default, Azure DevOps won't run a scheduled build if there have been no code changes since the last successful run. Set always: true if you want the pipeline to run regardless.

Verify with Scheduled Runs view. In the Azure DevOps UI, go to your pipeline and select "Scheduled runs" from the ... menu. This shows you the next few upcoming runs. If nothing shows there, your schedule isn't being picked up — regardless of what's in your YAML.

TL;DR

trigger: none doesn't just disable CI triggers — it disables scheduled runs too. If you want a schedule-only pipeline, remove the trigger: key entirely, or scope the CI trigger to something that won't match.

One line. No validation error. Hours of head-scratching.

More information

Triggers in Azure Pipelines - Azure Pipelines | Microsoft Learn

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

A complex system designed from scratch never works

A few years ago, I worked as an architect on a big mainframe rewrite. I still count it as one of my failures. Not because the technology was wrong, but because I couldn't convince the management team to simplify the approach. Years later, the organization is still struggling to get the new system up and running. I left the project at the time, because I couldn't put my name behind an approach that would take very long and cost a lot of money without a working system to show for it along the way. Gall’s Law That memory keeps coming back to me, because it's a textbook case of Gall's Law playing out in real life. Gall's Law , from John Gall's Systemantics , states it plainly: A complex system that works is invariably found to have evolved from a simple system that worked. A complex system designed from scratch never works, and it cannot be patched to make it work. You have to start over with a simple system that works. What does that mean in practice,...