---
title: Triggers
source: 'https://academia.sh/en/courses/ci-cd/triggers'
course: 'Continuous Integration and Delivery'
language: en
updated: '2026-09-13T05:08:18+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Compares three trigger types on the same calendar by run count, wasted runs, delay, and overlap'
    - "Measures the schedule trigger's marginal contribution when the event trigger is already present, and shows the gate that can be removed"
    - 'Counts the two faces of the path-based filter together: the wasted runs it blocks and the real change it misses'
    - "Ties the filter list's gap to the same class of error as the cache key's gap"
---

# Triggers

Event, schedule, and manual triggering are all run on the same two-week fictional calendar: run count, wasted runs made when nothing has changed or when a change does not affect the pipeline's output, the delay between a change and its run, and overlapping runs are counted, and then a path-based filter is added and the drop in wasted runs is measured together with the real change it misses.

The pipeline is defined, validated, and versioned. The previous two lessons used run counts
between 10 and 80 a day and calculated thresholds from those numbers, but never said where the
number came from. What starts a run is a **trigger**, and a trigger is a gate too — what it holds
is the run itself. A pipeline that never runs catches no defect; one that runs needlessly only
spends money and queue.

There are three types: bound to an **event** (when a change lands in the repository), bound to a
**schedule** (at set intervals), and pulled **manually** (when someone presses a button). The
three produce very different numbers on the same calendar. This lesson counts four of them: run
count, wasted runs, delayed feedback, and overlapping runs — then adds a path-based filter and
reads the same numbers again.

**PD43.** The calendar is two weeks of the fictional measurement network, produced with `node`;
the generator is self-written, seed **9173**. There are ten workdays and no weekend changes.
**PD44.** Path groups and their proportions are declared in the model; `docs/` does not affect
the pipeline's output, the others do. **PD45.** Run duration is 203 seconds — the critical path
measured in earlier lessons; an overlapping run starts before the previous one ends. **PD46.**
Delay is the time between a change and the **start** of the first run covering it; a run's own
duration is the same for every trigger, so it stays out of the delay. **PD47.** Manual triggering
is modeled at 17:30 on every workday and skipped with a probability of one in four. **PD48.** A
filtered-out run does not advance; uncovered changes carry over to the next run.

## Three Triggers, the Same Calendar

```js
// calendar.mjs — two-week fictional change calendar and triggers (model)
export const SEED = 9173;
export const generator = (t) => () => ((t = (t * 48271) % 2147483647) / 2147483647);
export const DAY = 86400, RUN = 203; // critical path duration measured in 05 (seconds)

// Path groups: an effective one changes the pipeline's output, an ineffective one does not.
export const PATHS = [["src/", 45, 1], ["test/", 20, 1], ["docs/", 15, 0],
  ["config/", 10, 1], ["shared-lib/", 10, 1]];
export const FILTER = ["src/", "test/", "config/"]; // shared-lib/ was not added to the list

export const calendar = () => {
  const rnd = generator(SEED), changes = [];
  for (let day = 0; day < 14; day++) {
    if (day % 7 >= 5) continue; // weekend
    for (let i = 3 + Math.floor(rnd() * 6); i > 0; i--) {
      const p = rnd() * 100;
      let t = 0, path = PATHS[PATHS.length - 1];
      for (const entry of PATHS) { t += entry[1]; if (p < t) { path = entry; break; } }
      changes.push({ at: day * DAY + Math.round((9 + rnd() * 9) * 3600), path: path[0], effective: path[2] });
    }
  }
  return changes.sort((a, b) => a.at - b.at);
};

export const TRIGGER = {
  event: (changes) => changes.map((x) => x.at),
  "time-4h": () => Array.from({ length: 84 }, (_, i) => Math.floor(i / 6) * DAY + (i % 6) * 4 * 3600),
  "time-nightly": () => Array.from({ length: 14 }, (_, day) => day * DAY + 2 * 3600),
  manual: () => { const rnd = generator(SEED + 1);
    return [...Array(14).keys()].filter((day) => day % 7 < 5 && rnd() > 0.25).map((day) => day * DAY + 17.5 * 3600); },
};

// Empty run: nothing has changed since the previous run. Irrelevant run: none of the changes it
// covers affect the pipeline's output. Overlapping: a run that starts before the previous one ends.
export const measure = (runs, changes) => {
  let empty = 0, irrelevant = 0, overlapping = 0, prev = -Infinity;
  runs.forEach((run, i) => {
    const covered = changes.filter((c) => c.at > prev && c.at <= run);
    if (!covered.length) empty++;
    else if (!covered.some((c) => c.effective)) irrelevant++;
    if (i > 0 && run - runs[i - 1] < RUN) overlapping++;
    prev = run;
  });
  const delay = [];
  let uncovered = 0;
  for (const c of changes.filter((x) => x.effective)) {
    const run = runs.find((t) => t >= c.at);
    if (run === undefined) uncovered++; else delay.push(run - c.at);
  }
  return { runs: runs.length, empty, irrelevant, overlapping, uncovered,
    avg: delay.reduce((a, b) => a + b, 0) / (delay.length || 1), longest: Math.max(0, ...delay) };
};

// Path-based filter: a run happens only if one of the changes it covers matches the list.
// A filtered-out run does not advance "prev"; uncovered changes carry over to the next run.
export const filter = (runs, changes, list = FILTER) => {
  const kept = [];
  let prev = -Infinity;
  for (const run of runs)
    if (changes.some((c) => c.at > prev && c.at <= run && list.includes(c.path))) { kept.push(run); prev = run; }
  return kept;
};
```

```js
// trigger.mjs — three trigger types on the same two-week calendar
import { calendar, TRIGGER, measure, PATHS, SEED } from "./calendar.mjs";

const changes = calendar();
const hr = (s) => (s / 3600).toFixed(1);
const distribution = PATHS.map(([prefix]) => `${prefix}${changes.filter((x) => x.path === prefix).length}`).join("  ");
console.log(`seed ${SEED}; ${changes.length} changes in two weeks, ${changes.filter((x) => x.effective).length} effective`);
console.log(`path distribution: ${distribution}`);

console.log("\ntrigger          runs  empty  irrelevant  wasted  overlap  avg delay  longest  uncovered");
const results = {};
for (const [name, make] of Object.entries(TRIGGER)) {
  const r = (results[name] = measure(make(changes), changes));
  console.log(name.padEnd(16) + String(r.runs).padStart(6) + String(r.empty).padStart(7) +
    String(r.irrelevant).padStart(12) + String(r.empty + r.irrelevant).padStart(10) + String(r.overlapping).padStart(9) +
    (hr(r.avg) + " hr").padStart(13) + (hr(r.longest) + " hr").padStart(9) + String(r.uncovered).padStart(13));
}

// The time trigger's added contribution when the event trigger is already there.
const eventRuns = TRIGGER.event(changes);
for (const name of ["time-4h", "time-nightly"]) {
  const combined = [...eventRuns, ...TRIGGER[name]()].sort((a, b) => a - b);
  const r = measure(combined, changes);
  console.log(`\nevent + ${name}: ${r.runs} runs (event alone ${results.event.runs}), ` +
    `empty ${r.empty}, avg delay ${hr(r.avg)} hr — the event trigger's own delay ${hr(results.event.avg)} hr`);
  console.log(`  of the ${r.runs - results.event.runs} runs added, ${r.empty} are empty; ` +
    `contribution to delay ${(results.event.avg - r.avg).toFixed(1)} seconds`);
}
```

```
seed 9173; 53 changes in two weeks, 46 effective
path distribution: src/21  test/12  docs/7  config/3  shared-lib/10

trigger          runs  empty  irrelevant  wasted  overlap  avg delay  longest  uncovered
event               53      0           7         7        2       0.0 hr   0.0 hr            0
time-4h             84     58           1        59        0       2.0 hr   3.9 hr            0
time-nightly        14      4           0         4        0      12.4 hr  16.8 hr            0
manual               6      0           0         0        0      22.2 hr 103.1 hr            0

event + time-4h: 137 runs (event alone 53), empty 84, avg delay 0.0 hr — the event trigger's own delay 0.0 hr
  of the 84 runs added, 84 are empty; contribution to delay 0.0 seconds

event + time-nightly: 67 runs (event alone 53), empty 14, avg delay 0.0 hr — the event trigger's own delay 0.0 hr
  of the 14 runs added, 14 are empty; contribution to delay 0.0 seconds
```

## Reading the Four Numbers

The event trigger makes 53 runs and its delay is **zero**: every change gives birth to its own
run. Its wasted runs are 7, all of one kind — changes that only touch `docs/` cost a full run
without changing the pipeline's output. Its overlapping runs are 2: two changes arrived less than
203 seconds apart and the second run started before the first finished. Overlap does not only
spend a slot; when the first run finishes, its result already belongs to a stale tree.

The four-hour schedule trigger is bad **on both axes at once**. It makes 84 runs — 58 percent
more than the event trigger — and **58 of them are completely empty**, meaning no change has come
in since the previous run. Its wasted-run rate is 59/84, that is, 70 percent. Against that, its
average delay is not zero but **2 hours**, with the longest wait at 3.9 hours. It runs more, and
it reports later.

Manual triggering is the most deceptive row in the table. Its wasted runs are **zero** and its
run count is only 6; on the cost column it wins by a wide margin. The delay column makes the
call: an average of 22.2 hours, a longest of **103.1 hours** — more than four days. Zero wasted
runs is not a virtue either: runs are so sparse that each one covers dozens of changes at once, so
none of them go to waste. That same sparseness leaves it unclear **which change broke it** when a
run turns red.

## The Gate That Can Be Removed

Triggers can also be used together, and in practice the most common combination is event plus
schedule. The measurement asks what this combination adds. When the four-hour schedule trigger is
added alongside the event trigger, run count rises from 53 to **137**; **all 84 of the 84 added
runs are empty**, because every change has already triggered its own run. Its contribution to
delay is **0.0 seconds**.

By this course's measure, the decision is clear: a gate is worth only what it alone catches, and
this gate has nothing that belongs only to itself. The four-hour schedule trigger, with the event
trigger already present, is **the gate that should be removed** — 84 empty runs, zero gain.

The nightly run adds 14 runs by the same measure and also shows zero contribution on this
calendar. Whether it should be removed cannot be measured here, and that is written down: this
calendar only models changes that **arrive in the repository**. The nightly run's defense is a
change outside it — a dependency's content growing without its list file changing. Earlier
lessons measured that the cache key misses exactly this kind of change. The nightly run is the
antidote to that blindness, but it is invisible here; deciding it needs a different calendar.

## The Path Filter

The event trigger's only flaw was wasted runs: 7 runs were spent on `docs/` changes. The known
remedy is a **path filter**, which ties a run to the change of only certain paths. The filter
list carries three paths; the shared library directory, added to the repository later, was not
written into the list.

```js
// filter.mjs — path-based filter: the drop in wasted runs and the real change it misses
import { calendar, TRIGGER, measure, filter, FILTER } from "./calendar.mjs";

const changes = calendar(), hr = (s) => (s / 3600).toFixed(1);
const arrow = (a, b) => `${a} -> ${b}`.padStart(12);
console.log(`filter list: ${FILTER.join(" ")}  (shared-lib/ is not on the list)`);
console.log("\ntrigger            runs      wasted  uncovered    avg delay");
for (const [name, make] of Object.entries(TRIGGER)) {
  const runs = make(changes), before = measure(runs, changes), after = measure(filter(runs, changes), changes);
  console.log(name.padEnd(16) + arrow(before.runs, after.runs) + arrow(before.empty + before.irrelevant, after.empty + after.irrelevant) +
    arrow(before.uncovered, after.uncovered) + `  ${hr(before.avg)} -> ${hr(after.avg)} hr`);
}

// Changes on a path not in the filter list: cannot trigger their own run, ride along on another one.
const filtered = filter(TRIGGER.event(changes), changes);
const outside = changes.filter((x) => x.path === "shared-lib/");
const wait = outside.map((x) => { const run = filtered.find((t) => t >= x.at); return run === undefined ? null : run - x.at; });
const riding = wait.filter((w) => w !== null);
console.log(`\nshared-lib/ changes: ${outside.length}, none of them triggered its own run`);
console.log(`  ${riding.length} of them rode along on another change's run, averaging ` +
  `${hr(riding.reduce((a, b) => a + b, 0) / riding.length)} hr of delay, the longest being ${hr(Math.max(...riding))} hr`);
console.log(`  ${wait.length - riding.length} of them never ran at all across the two weeks`);

// The gate's account: the wasted runs it holds, the real changes it falsely holds, the delay it adds.
const eventRaw = measure(TRIGGER.event(changes), changes), eventFiltered = measure(filtered, changes);
const corrected = measure(filter(TRIGGER.event(changes), changes, [...FILTER, "shared-lib/"]), changes);
console.log(`\ngate account (event trigger + path filter)`);
console.log(`  holds        : ${eventRaw.empty + eventRaw.irrelevant} wasted runs (docs/ changes)`);
console.log(`  falsely holds: ${outside.length} real changes, ${eventFiltered.uncovered} of them never ran at all`);
console.log(`  adds         : average delay ${hr(eventRaw.avg)} -> ${hr(eventFiltered.avg)} hr`);
console.log(`  once the list is fixed: ${corrected.runs} runs, ${corrected.empty + corrected.irrelevant} wasted, ` +
  `${corrected.uncovered} uncovered, delay ${hr(corrected.avg)} hr`);
```

```
filter list: src/ test/ config/  (shared-lib/ is not on the list)

trigger            runs      wasted  uncovered    avg delay
event               53 -> 36      7 -> 0      0 -> 1  0.0 -> 0.6 hr
time-4h             84 -> 23     59 -> 0      0 -> 0  2.0 -> 2.6 hr
time-nightly        14 -> 10      4 -> 0      0 -> 0  12.4 -> 12.4 hr
manual                6 -> 6      0 -> 0      0 -> 0  22.2 -> 22.2 hr

shared-lib/ changes: 10, none of them triggered its own run
  9 of them rode along on another change's run, averaging 3.1 hr of delay, the longest being 19.9 hr
  1 of them never ran at all across the two weeks

gate account (event trigger + path filter)
  holds        : 7 wasted runs (docs/ changes)
  falsely holds: 10 real changes, 1 of them never ran at all
  adds         : average delay 0.0 -> 0.6 hr
  once the list is fixed: 46 runs, 0 wasted, 0 uncovered, delay 0.0 hr
```

## The Filter's Two Faces

If the first row is read alone, the filter is a success: on the event trigger, runs drop from 53
to 36, and wasted runs from 7 to zero. Once the second number is looked at, the arithmetic falls
apart. The runs that dropped number **17**, yet only **7** were wasted. The 10 runs in between
were runs of changes that really did affect the pipeline's output.

All ten changes are in the shared library directory and **none triggered its own run**. Nine
entered the pipeline by riding along on a later change's run, with an average delay of 3.1 hours
and a longest of 19.9 hours. One was **never run at all** across the two weeks. Average delay
rose from 0.0 to 0.6 hours — small-looking, because 36 of the 46 effective changes were never
delayed; the average hides what happened to the ten.

The gate account is complete in three lines: the filter **holds 7 wasted runs**, **falsely holds
10 real changes**, and one of them never runs at all. What it falsely holds outnumbers what it
correctly holds. As it stands, the gate is harmful, and removing it would be better.

Removing it is still not the right fix: the problem is not in the gate but in its list. Once the
shared library path is added, the measurement gives the last row: **46 runs, 0 wasted, 0
uncovered, 0.0 hours of delay** — seven fewer runs than the raw event trigger, with no loss. That
is the filter's value set up correctly; its cost set up wrong is the three lines above.

The filter's effect on the schedule trigger is even larger: 84 runs drop to 23, 59 wasted runs
drop to zero, and no uncovered change is born, because the changes a filtered-out run would have
covered carry over to the next one. On manual triggering, nothing changes at all: all six of the
six runs already cover a change that matches the filter.

This measurement's real finding is hiding in the list. The pipeline's inputs are declared in
**two separate places** across this course: in the cache key, and in the trigger filter. Both are
hand-written, both go stale as the repository grows, and both produce the same result when they
are incomplete — a change exists, but the gate does not run. In the earlier measurement, this let
two defects escape; here it delayed ten changes and never ran one at all.

## Summary

- On the same two-week calendar: the event trigger gave 53 runs and zero delay, the four-hour
  schedule trigger 84 runs and 2 hours, the nightly run 14 runs and 12.4 hours, manual triggering
  6 runs and 22.2 hours (longest 103.1).
- The four-hour schedule trigger is bad on both axes: it runs 58 percent more than the event
  trigger (59/84 wasted) and reports later. Manual triggering's zero wasted runs come from
  sparseness, not virtue, and that sparseness hides which change broke it.
- With the event trigger present, the four-hour schedule trigger is **the gate that should be
  removed**: all 84 runs it adds are empty and its contribution to delay is 0.0 seconds. The
  nightly run's defense is a change outside the repository, which this calendar does not
  measure.
- The path filter brought the event trigger's runs down from 53 to 36, but of the 17 dropped
  runs only 7 were wasted. Nine of the 10 changes in the unlisted directory rode along on another
  run with an average delay of 3.1 hours; one never ran across the two weeks.
- Gate account: 7 wasted runs held, 10 real changes falsely held. With the list fixed the numbers
  reach 46 runs, 0 wasted, 0 uncovered, 0.0 hours of delay — the defect is not in the gate but in
  the list. The pipeline's inputs are declared by hand in two places (the cache key and the
  trigger filter), and an incomplete list gives the same result in both: the change exists, the
  gate does not run.

## Next Step

The pipeline is complete at the end of this topic: its gates were counted, what each gate alone
catches was measured, its duration was brought down to the 203-second critical path, and where
and when it runs was decided. All of it rested on one silent assumption: that a change coming out
of a green run **had reached production**. But a green run only says the change is ready in the
pipeline, not that it is in production. How it enters production is a
separate decision — all at once, to a slice first, by switching between two environments, or from
behind a flag in the code? Each brings its own rollback path and observation criterion. The
pipeline's last gate stands there, and what it holds, what it misses, and how long it holds are
measured with the same three numbers. The next topic takes up that decision.
