DEV Community

Roberto Luna
Roberto Luna

Posted on

Adding Concurrency with `cancel-in-progress` to GitHub Actions CI to Cut Build Minutes

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Extra API calls – each run now consumed additional minutes just to poll the API.
  2. 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
+
 # ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Why This Structure?

  1. 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.
  2. cancel-in-progress: true – Guarantees that any older run in the same group is aborted the moment a newer run is queued.
  3. Placement – The concurrency block lives at the top‑level of the YAML (outside jobs). 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I also added a quick sanity check in the workflow logs:

- name: Show concurrency group
  run: echo "Concurrency group: ${{ github.workflow }}-${{ github.ref }}"
Enter fullscreen mode Exit fullscreen mode

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)