Adding concurrency: cancel-in-progress to the CI workflow to cut wasted GitHub Actions minutes
TL;DR: I added the concurrency block with cancel-in-progress: true to the ci-e2e.yml workflow. The change stops overlapping runs on the same branch, instantly freeing minutes that were previously consumed by stale jobs.
The Problem
Our repository craveview runs an end‑to‑end test suite on every push and on a scheduled cron. The workflow lives in .github/workflows/ci-e2e.yml. Because developers push frequently, especially during feature‑branch development, we often saw two or more CI runs queued for the same branch:
Run actions/checkout@v3
Run setup-node@v3
Run npm ci
Run npm run test:e2e
When a second push arrived before the first run finished, GitHub started a new job while the previous one kept running. The older run completed its 12‑minute test suite even though its results were obsolete. This inflated our monthly GitHub Actions minutes bill and slowed feedback for the team.
The symptom was simple: the Actions tab showed multiple runs for the same branch, and the Usage page reported a steady increase in minutes even when no new code was merged.
What I Tried First
My first instinct was to add a manual “skip if already running” guard at the beginning of the job:
steps:
- name: Check for running jobs
run: |
if gh run list --workflow ci-e2e.yml --branch ${{ github.ref_name }} --status in_progress; then
echo "Another run is in progress, exiting."
exit 0
fi
I used the GitHub CLI (gh) to list in‑progress runs and bail out early. The idea worked locally, but it introduced a race condition: two pushes could still start the guard step at the same time, both see no running jobs, and both continue. Moreover, the extra CLI call added ~5 seconds to every run and required a GITHUB_TOKEN with additional permissions, which felt like over‑engineering for a problem that GitHub already solves.
The Implementation
GitHub Actions provides a built‑in concurrency feature that groups runs by a key and optionally cancels any in‑progress run when a new one arrives. The syntax is straightforward and does not require extra steps or tokens.
Diff Overview
The commit 76536eca modified only one file, adding a 7‑line block:
@@ -9,6 +9,13 @@ on:
# de Actions; el objetivo (detectar drift externo) no requiere diario
- cron: "40 6 * * 1,4"
+# Cancel previous runs on the same branch if a new push arrives
+# This prevents duplicate work and saves CI minutes.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
Full ci-e2e.yml after the change
Below is the complete workflow with the new concurrency block highlighted:
name: CI – E2E
on:
push:
branches:
- main
- develop
- feature/*
pull_request:
branches:
- main
- develop
schedule:
# Run nightly on Monday and Thursday at 06:40 UTC
- cron: "40 6 * * 1,4"
# --------------------------------------------------------------
# NEW: Cancel previous runs on the same branch
# --------------------------------------------------------------
concurrency:
# The key combines the workflow name and the ref (branch or tag)
group: ${{ github.workflow }}-${{ github.ref }}
# When a new run is triggered, abort the old one
cancel-in-progress: true
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Run E2E tests
run: npm run test:e2e
Why this group key?
-
${{ github.workflow }}resolves toCI – E2E. -
${{ github.ref }}resolves to the full ref, e.g.refs/heads/feature/login. - The concatenation gives a unique key per workflow‑branch pair, ensuring that only runs on the same branch cancel each other. Pull‑request runs (which use a ref like
refs/pull/123/merge) get their own group, so they don’t interfere with branch pushes.
What happens under the hood?
When a new push triggers the workflow:
- GitHub checks if there is an existing run with the same
group. - If found and
cancel-in-progress: true, the older run receives acancelevent. - The runner stops the job, marks it as Cancelled, and frees the allocated minutes.
- The new run proceeds normally.
No additional code, no external CLI, no extra permissions. The feature is declarative and works across all runners (self‑hosted or GitHub‑hosted).
Verifying the change
After merging the PR, I opened a new branch, pushed two commits in rapid succession, and watched the Actions tab:
- Run #1 started, then quickly changed status to Cancelled as Run #2 began.
- The Usage page showed ~12 minutes saved (the average runtime of the cancelled job).
I also added a quick sanity check in the workflow logs:
- name: Show concurrency key
run: echo "Concurrency group: ${{ github.workflow }}-${{ github.ref }}"
The log printed the expected key, confirming that the interpolation works as intended.
Key Takeaway
Use GitHub Actions’ built‑in concurrency with cancel-in-progress instead of custom scripts to prevent overlapping CI runs. It’s a one‑liner, requires no extra permissions, and instantly reduces wasted minutes—especially valuable for high‑frequency push environments.
What's Next
Now that duplicate runs are eliminated, the next step is to parallelize the E2E suite to bring the total runtime down further. I plan to:
- Split the test suite into shards using
npm-run-alland a matrix strategy. - Add a cache for
node_modulesand Cypress binary to shave off install time. - Monitor the new workflow’s cost impact and adjust the concurrency group if we introduce additional workflow files (e.g.,
ci-unit.yml).
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
Tags: #vibecoding #buildinpublic #github-actions #ci #devops #javascript #cypress
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/craveview · 2026-10-01
#playadev #buildinpublic
Top comments (0)