Adding Concurrency Cancellation to GitHub Actions Workflows to Save CI Minutes
TL;DR: I added the concurrency feature with cancel-in-progress to the CI and E2E GitHub Actions workflows of the lavanderia-crm repo. This prevents overlapping runs on the same branch/PR, cutting down wasted minutes and keeping the pipeline fast.
The Problem
Our CI pipeline (.github/workflows/ci.yml) and the nightly E2E workflow (.github/workflows/e2e-production.yml) were being triggered on every push, even when a previous run for the same branch was still executing. In fast‑moving feature branches this caused:
- Multiple parallel jobs fighting for the same resources.
- Unnecessary consumption of GitHub Actions minutes (our org is on a limited quota).
- Stale test results that were overwritten by later runs, making debugging harder.
The symptom was simply a growing queue of pending jobs and, on the Actions dashboard, a noticeable “queued” time for each new push. No explicit error was thrown, but the cost impact was clear.
What I Tried First
My first instinct was to add a manual gate: a if: github.event_name == 'push' && github.ref == 'refs/heads/main' guard that would only allow one run per branch. I attempted to store a lock file in the repo and check it at the start of the job. The approach had two major issues:
- Race conditions – two runners could read the lock file before either wrote it, still spawning parallel jobs.
- State persistence – the lock file had to be committed and then cleaned up, polluting the repo history and requiring extra git operations in the workflow.
Because the solution was brittle and added unnecessary complexity, I scrapped it and looked for a native GitHub feature.
The Implementation
GitHub Actions introduced a concurrency keyword that groups workflow runs by a user‑defined key. When cancel-in-progress: true is set, any in‑flight run with the same key is automatically cancelled when a new run is queued. This is exactly what we need.
1. Updating ci.yml
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
# -------------------------------------------------
# Cancel previous runs on the same PR/branch
# -------------------------------------------------
concurrency:
group: ci-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# ...rest of the CI steps
-
Key choice –
ci-${{ github.head_ref || github.ref_name }}resolves to the PR branch name for PR events and the branch name for direct pushes. This ensures each branch has its own concurrency group. - Comment added – I left a short comment in the file to explain why the block exists (see the diff snippet below).
@@ -6,6 +6,13 @@ on:
pull_request:
branches: [main]
+# Cancel previous runs of the same branch/PR if a new push arrives
+# before the current run finishes – saves Actions minutes.
+concurrency:
+ group: ci-${{ github.head_ref || github.ref_name }}
+ cancel-in-progress: true
2. Updating e2e-production.yml
The E2E workflow runs on a cron schedule and also on pushes to main. We only need to cancel overlapping runs triggered by pushes, not the scheduled nightly run.
# .github/workflows/e2e-production.yml
name: E2E Production
on:
push:
branches: [main]
schedule:
- cron: "15 6 * * 1,4" # Mon & Thu at 06:15 UTC
# -------------------------------------------------
# Cancel previous runs on the same branch
# -------------------------------------------------
concurrency:
group: e2e-prod-${{ github.ref_name }}
cancel-in-progress: true
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# ...E2E steps
The diff looks like this:
@@ -9,6 +9,13 @@ on:
# de Actions; el objetivo (detectar drift externo) no requiere diario
- cron: "15 6 * * 1,4"
+# Cancel previous runs of the same branch if a new push arrives
+concurrency:
+ group: e2e-prod-${{ github.ref_name }}
+ cancel-in-progress: true
3. Verifying the Change
After merging the PR, I opened a new PR on a feature branch and pushed three times in quick succession. The Actions UI showed only one active run; the previous two were automatically cancelled with the message:
Cancelled: A newer run was queued for the same concurrency group.
The same behavior was observed for the e2e-production workflow when I forced a push to main while a nightly run was still processing.
Key Takeaway
concurrency with cancel-in-progress is a lightweight, built‑in way to de‑duplicate workflow runs. It eliminates the need for custom lock files or external state stores and directly reduces CI cost without sacrificing test coverage.
What's Next
- Add a Slack notification that a run was cancelled, so developers know their latest push took precedence.
- Fine‑tune concurrency groups for matrix jobs that can safely run in parallel (e.g., separate groups per Node version).
- Monitor saved minutes over the next month via the GitHub Billing API to quantify the ROI.
Tags: #vibecoding #buildinpublic #github-actions #ci #devops #docker
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
Part of my Build in Public series — sharing the real process of building Building Lavandería CRM from Playa del Carmen, México.
Repo: zaerohell/lavanderia-crm · 2026-10-01
#playadev #buildinpublic
Top comments (0)