Skip to main content

Gate Cadence Is Part of What a Gate Costs

In one line: a control that reports the same thing every time only needs to run when the thing it reports on could have moved — so every gate declares a cadence alongside its blocking status, and "every push" is a choice that must be earned rather than the default.

The canon has been careful that a gate must observe something (§7.3) and must not claim more than it checks (§14). It has said nothing about how often a gate should run, and the omission is expensive. Measured on a flagship project, 2026-08-03: ~84 billable minutes per push across three workflows, ≈ $0.67 a push, ≈ $69 in seven days. Roughly a third of that bought nothing.

The test is whether the gate's INPUT can change between runs.

CadenceThe gate's inputExamples
Every pushcode or dependencies the PR can edittest suites, lint, dependency audits, image scans, IaC/Dockerfile misconfig, secret-scan of the PR diff
Weekly / scheduledsomething a branch cannot alter, or an upstream clocka new-CVE re-scan of UNCHANGED source, a dependency-advisory refresh, an upstream-clock check. Note what is absent: SAST is not here — a PR edits the source SAST reads, so its findings move per commit however retrievable the last run's artifact is. Only re-scanning source that did not change qualifies.
On demand (workflow_dispatch)anything scheduled, when a release needs a fresh answerthe whole weekly tier

Two examples that look scheduled and are not — an earlier revision of this table listed both, and both were wrong for the same reason: the subject was confused with the finding.

  • A full-history secret scan is not invariant across branch commits. Every new commit extends the history the scan covers, so a secret introduced on the branch is inside the next full scan's subject. What was invariant was the two findings already in history, not the scan. Relegating it to weekly postpones a new finding until after merge. The correct move is the opposite of narrowing: keep a per-commit scan and widen it to the push range (before..sha). Three cases that range does not cover, taken from a working implementation rather than sketched here: a branch CREATION supplies an all-zero before that names no object; a force-push can supply a before that no longer exists (git cat-file -e "$BEFORE^{commit}" is the test); and in either case the obvious fallback of sha~1..sha scans ONE commit, so a push carrying several leaves every earlier one unscanned — precisely the pushes with no prior scan to fall back on. The fallback is an EMPTY range, i.e. all reachable history: slower, rare by construction, and correct. Related: changing the scanner's ruleset invalidates every earlier scan, since the ruleset is what decides whether a finding was a finding as well as the PR range (base..head), because a direct push to a branch with an admin bypass is exactly the path a PR-range scan misses.
  • A licence / notice re-measure takes a dependency manifest as input, and a PR can edit that manifest. It belongs on every push.

The general form of the error: "this check keeps returning the same answer" is an observation about recent outputs, not a proof about the input. Ask what the gate READS, and whether a branch commit can change it.

Three concrete diagnoses from that project, each of which had been running on every push since it was written:

  • A full-history secret scan that had produced byte-identical output on every run since the job was authored — its only findings sat in git history, and no commit in that window added another. ~260 billable minutes a week. Stated deliberately as a historical observation, not an invariant: as the correction above establishes, a branch commit carrying a new secret extends the scanned history and would change the output, so the repetition shows only that those particular commits introduced nothing — which is exactly the inference this section warns against. The finding here is that ~260 minutes a week bought a repeated answer, not that the answer was incapable of changing; the fix is to widen the scan's range, not to retire it.
  • Two SAST jobs uploading to a code-scanning API that returns 403 on a private repo without Advanced Security. Their results survived only as a build artifact nobody opens. ~1,470 minutes a week, and the project's own ADR already recorded that one of them "is not an active control" — the register and the invoice disagreeing.
  • The same container image built three times per push, because two jobs each spun up their own runner to look inside an image a third job already had in its local daemon.

Two rules follow, and the second is the load-bearing one.

  1. Declare a cadence per gate, next to its blocking status. The quality-gates document already answers "does this block?"; it must also answer "how often does this run, and why that often?"

  2. Moving a gate off per-push must not change what blocks. Only a gate that is not in the required-check set is eligible, and the move is recorded with its measurement. A cadence change that quietly drops a blocking control is a coverage cut wearing a cost-saving label — the exact substitution §14 exists to prevent. Weekly detection of a condition that changes weekly is not less coverage; weekly detection of a condition that changes per commit is.

Why: a gate nobody can afford gets deleted, and a gate that costs nothing to skip gets skipped. Both failures end with a control that is declared and not operating. Pricing cadence deliberately is how a gate set stays affordable enough to keep — and the measurement is cheap: one completed run of each workflow, per-job durations, multiplied by runs per week.

Evidence, and how to re-derive it. The figures above are a measurement of one project's Actions usage on 2026-08-03, not a published benchmark; they are recorded here so an adopter can check whether their own numbers resemble them, and re-run the collection rather than trust the total. Per-job billable duration comes from the run itself:

# every job of a recent completed run, with its duration
# --all: a workflow DISABLED as part of the remediation is otherwise invisible,
# and its prior cost reads as zero — the one number you most need.
# --paginate: the jobs API pages at 30, so a big matrix silently truncates.
gh run list --workflow=<file> --all --status=completed --limit 1 \
--json databaseId -q '.[0].databaseId' \
| xargs -I{} gh api --paginate repos/<owner>/<repo>/actions/runs/{}/jobs \
-q '.jobs[] | "\(.name)\t\(.started_at)\t\(.completed_at)"'

# runs per week, per workflow — the multiplier.
# --limit is REQUIRED: gh defaults to 20, so a busy workflow silently undercounts
# (that default is exactly what makes a cost figure look reassuring).
# The cutoff is written for both date(1) dialects: GNU first, BSD/macOS fallback.
# A FULL timestamp, not %F: truncating to midnight makes the window 7-to-8 days
# depending on the hour you run it, overstating a steady week by up to ~14%.
WEEK_AGO="$(date -u -d '7 days ago' +%FT%TZ 2>/dev/null || date -u -v-7d +%FT%TZ)"
# `set -o pipefail` and an explicit abort: without them, an expired token or a
# bad workflow selector makes `gh run list` fail, the loop reads nothing, and the
# pipeline exits 0 — recording ZERO COST for a gate you were about to retire.
# A cost of zero derived from having looked at nothing is the same vacuous pass
# this section is about.
set -o pipefail
ids="$(gh run list --workflow=<file> --all --created ">=$WEEK_AGO" \
--status=completed --limit 1000 --json databaseId -q '.[].databaseId')" \
|| { echo "run list FAILED — do not treat the result as evidence" >&2; exit 1; }
[ -n "$ids" ] || { echo "no runs in window — verify the selector before believing it" >&2; exit 1; }
printf '%s\n' "$ids" | while read -r id; do
# filter=all: the endpoint returns ONLY the latest attempt by default, and a
# rerun's earlier attempts burned billable minutes too. --paginate pages; it
# does not change the attempt filter.
# Emit run id and job NAME, not just timestamps: the subtotals below claim to
# be this calculation "restricted to the named jobs", and a row of two
# timestamps cannot be attributed to a job. Job sets also vary across runs, so
# the single sampled run is not a usable mapping.
gh api --paginate "repos/<owner>/<repo>/actions/runs/$id/jobs?filter=all" \
-q '.jobs[] | "\(.name)\t\((.labels // []) | join(","))\t\(.started_at)\t\(.completed_at)"' \
| sed "s|^|$id\t|" \
|| { echo "jobs fetch FAILED for run $id" >&2; exit 1; }
done

Sum the jobs of EVERY run in the window rather than multiplying one sampled run by a raw run count: a week contains queued, cancelled, failed and path- or matrix-filtered runs whose billable job sets differ, so the count is not a uniform multiplier and using it as one can miss in either direction. Billable minutes round each job UP to the whole minute, which is why a set of short jobs costs more than the wall-clock suggests. There is no single per-minute rate. Linux, Windows, macOS, larger and self-hosted runners are billed differently (self-hosted at zero), so a total built by multiplying minutes by one rate is wrong for any workflow that mixes them — which is why the rows above capture each job's runner LABELS. Multiply per runner class against the plan's published rates, or skip the arithmetic entirely and read billed usage from the billing API, which is the only figure that is authoritative rather than reconstructed. A dollar total whose captured artifact cannot say which rate applied is not re-derivable, and re-derivability is the whole claim this block makes. The three subtotals (~260 and ~1,470 minutes a week, and the thrice-built image) are the same calculation restricted to the named jobs. Not recorded as a stored artifact, and that is a gap, not a style: these were read from the live Actions API on the day. GitHub expires run logs on a retention window, so the source is not merely uncommitted — it becomes unreachable, and §7.8's convention of naming in-repo artifact paths has nothing to point at here. Stated rather than implied, per §14. An adopter re-deriving these a month later will get different absolute numbers, and should: what must survive is the method and the test, not the totals.

So capture the artifact when you run this. Redirect both commands above into a dated file and commit it beside the decision that cites it — the measurement costs nothing to keep and cannot be reconstructed once the window closes. A cadence change argued from a number nobody can re-read is the same claim-without-a-check shape §14 exists to name, only pointed at ourselves.

Honest limit: none of this is mechanized. There is no detector that reads a workflow file and objects to a report-only job on a per-push trigger. The rule is a review question — could this gate's input have changed since the last run? — and until a detector exists it must be counted as recommended (not enforced), per §14's own tier honesty.