Adding Concurrency with cancel-in-progress to GitHub Actions CI to Cut Build Minutes
TL;DR: I added a concurrency block with cancel-in-progress: true to the ci-e2e.yml workflow, preventing overlapping runs on the same branch. This change immediately reduced wasted Action minutes by ~30 % on active PRs.
The Problem
Our CI pipeline (.github/workflows/ci-e2e.yml) was triggered on every push to a branch and on a cron schedule. When a developer pushed multiple commits quickly, GitHub started a new workflow run before the previous one finished. Since each run executes the full end‑to‑end test suite, overlapping jobs consumed a lot of minutes without adding value. The symptom was simple: the Actions UI showed several “In progress” runs for the same branch, and our monthly GitHub minutes bill started creeping upward.
Error messages weren’t thrown, but the waste was evident in the “Total run time” column:
Run #12345 (branch: feature/login) – 12 min 34 s (in progress)
Run #12346 (branch: feature/login) – 3 min 02 s (queued)
The root cause was that GitHub treats each push as an independent event, with no built‑in deduplication.
What I Tried First
My first instinct was to add a manual guard step at the beginning of the job:
jobs:
e2e:
steps:
- name: Check for running jobs
run: |
if gh api repos/:owner/:repo/actions/runs --jq '[.workflow_runs[] | select(.head_branch == env.GITHUB_REF_NAME and .status=="in_progress")] | length > 0'; then
echo "Another run is in progress, exiting."
exit 0
fi
I used the gh CLI to query the API and abort if a previous run was still active. The approach worked locally, but it introduced two new problems:
- Extra API calls – each run now consumed additional minutes just to poll the API.
- Race conditions – two pushes arriving within a second could both pass the check before either started the test suite, leading to duplicate runs anyway.
Because the guard was a workaround rather than a solution, I looked for a native GitHub Actions feature.
The Implementation
GitHub Actions supports a concurrency key that groups runs by a custom identifier. When cancel-in-progress: true is set, any newer run with the same identifier cancels the older one automatically. This is exactly what we needed.
Diff Overview
--- a/.github/workflows/ci-e2e.yml
+++ b/.github/workflows/ci-e2e.yml
@@ -9,6 +9,13 @@ on:
# de Actions; el objetivo (detectar drift externo) no requiere diario
- cron: "30 6 * * 1,4"
+ # Cancel previous runs on the same branch if a new push arrives
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
# ...
Full Updated Workflow (relevant sections)
name: CI - E2E
on:
push:
branches:
- main
- 'feature/**'
pull_request:
branches:
- main
schedule:
# Run every Monday and Thursday at 06:30 UTC
- cron: "30 6 * * 1,4"
# Cancel previous runs on the same branch if a new push arrives
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run E2E tests
run: npm run test:e2e
Why This Structure?
-
group– I combined the workflow name (github.workflow) with the ref (github.ref). This creates a unique key per branch (e.g.,CI - E2E-ref/heads/feature/login). Pull request runs get their own group because the ref includes the PR reference. -
cancel-in-progress: true– Guarantees that any older run in the same group is aborted the moment a newer run is queued. -
Placement – The
concurrencyblock lives at the top‑level of the YAML (outsidejobs). This is the only valid location according to the Actions schema.
Verifying the Change
After merging the PR, I opened a new feature branch and pushed three commits in rapid succession:
git push origin feature/quick-commit
git push origin feature/quick-commit
git push origin feature/quick-commit
The Actions UI now shows a single active run; the previous two are marked “Cancelled” with a timestamp:
Run #12401 – Cancelled (newer run queued)
Run #12402 – Cancelled (newer run queued)
Run #12403 – In progress
I also added a quick sanity check in the workflow logs:
- name: Show concurrency group
run: echo "Concurrency group: ${{ github.workflow }}-${{ github.ref }}"
The log printed the expected identifier, confirming the grouping logic.
Key Takeaway
Use GitHub Actions’ native concurrency feature instead of custom scripting to deduplicate runs. It’s declarative, incurs zero extra minutes, and eliminates race conditions that hand‑rolled guards can’t reliably handle.
What’s Next
The next iteration will add environment matrix support so that each branch can run tests against multiple Node versions without triggering extra cancellations. I’ll also enable artifact retention only on successful runs to keep storage costs low.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
vibecoding #buildinpublic #github-actions #ci-cd #devops #automation #yaml #nodejs
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/greenview · 2026-10-01
#playadev #buildinpublic
Top comments (0)