Managing continuous integration and delivery pipelines requires more than simply defining steps; it demands a rigorous approach to resource allocation and state management. In the context of GitHub Actions, the frequent push of new commits to pull request branches can trigger a cascade of redundant workflow runs, wasting computational resources and potentially causing race conditions in deployment or testing environments. The native concurrency key provides a robust mechanism to address this by allowing developers to define groups of related runs and dictate whether new runs should cancel existing ones. While third-party actions once served as a workaround for these limitations, modern GitHub Actions workflows leverage built-in expression capabilities to distinguish between pull request iterations and critical production branches, ensuring that only the most relevant jobs execute while preserving the integrity of main branch deployments.
The Mechanics of Native Concurrency Control
GitHub Actions offers a native concurrency option that controls how workflows run in parallel. This feature is essential for managing ongoing jobs effectively, preventing resource wastage, and streamlining the development process. The core of this functionality lies in the cancel-in-progress parameter, which automatically cancels in-progress jobs associated with previous workflow runs when new workflow runs are triggered. By enabling this option, teams ensure that only the most recent workflow runs are executed, eliminating redundancy and preventing conflicts that might arise from multiple simultaneous deployments or tests.
The implementation requires two key components: a group identifier and the cancel-in-progress flag. The group defines the scope of the cancellation logic. Runs that share the same group name are considered part of the same logical sequence. When cancel-in-progress is set to true, any running or queued jobs in that group are terminated in favor of the new run.
yaml
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
In this configuration, the github.ref context variable ensures that all workflows triggered by the same branch or tag are grouped together. When a new commit is pushed to that branch, the new workflow run shares the same group name as the previous one, triggering the cancellation of the older run.
Conditional Logic for Branch-Specific Behavior
A naive application of cancel-in-progress: true can be detrimental to production stability. While it is desirable to cancel outdated tests on a feature branch during rapid development, cancelling a deployment on the main branch can lead to failed releases or inconsistent states. Therefore, advanced concurrency strategies require conditional logic that differentiates between pull request events and pushes to protected branches.
GitHub Actions supports expressions within the concurrency block, allowing for dynamic configuration based on event types. However, early attempts to use conditional statements directly on the cancel-in-progress flag often failed because the runs were not queued as expected when the flag was dynamically set to false. Empirical evidence shows that simply toggling the flag based on branch names can lead to unexpected behaviors where jobs are not cancelled even when they should be, or conversely, where they are cancelled when they should queue.
A robust solution involves manipulating the group name itself. By assigning a unique group ID to runs on the main branch, the cancel-in-progress mechanism naturally becomes inactive for those runs because no other run shares that specific group ID. For pull requests, the group ID remains consistent across pushes to the same branch, enabling cancellation.
yaml
concurrency:
# Make sure every job on main has unique group id (run_id), so cancel-in-progress only affects PR's
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
In this pattern:
- github.head_ref is only defined for pull_request events. If it exists, the group is formed by the workflow name and the reference (github.ref).
- If github.head_ref is not defined (e.g., a push to main), the expression falls back to github.run_id, which is unique for every single workflow run.
- Because each run on main has a unique group ID, no other run can cancel it, effectively serializing deployments while allowing pull request runs to be cancelled.
Another strategy explicitly sets cancel-in-progress based on the event name. This approach separates the logic for testing and deployment.
yaml
concurrency:
# PRs: cancel outdated runs
# Main: queue runs (don't cancel deployments)
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Here, the group name uses github.event.pull_request.number for pull requests, ensuring that all runs for a specific PR are grouped together. For pushes to main, it uses github.sha, creating a unique group for each commit. The cancel-in-progress flag is then set to true only when the event is a pull request. This ensures that deployments on the main branch are never cancelled, even if they are queued, while allowing rapid iteration on pull requests.
Managing Fallback Values and Context Availability
When constructing group names, it is crucial to handle context availability carefully. Properties like github.head_ref are only defined for specific events, such as pull_request. If a workflow listens to multiple event types, such as both push and pull_request, using an undefined property can result in syntax errors or empty values.
To mitigate this, fallback values must be provided. The github.run_id is a reliable fallback because it is always available and unique to each workflow run. This ensures that the concurrency group is always populated with a valid string, preventing configuration errors.
yaml
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
It is important to note that expressions are supported for the cancel-in-progress parameter. The available contexts for these expressions include github (which provides access to various GitHub-specific variables) and inputs (which allows access to input parameters defined in the workflow). This flexibility enables complex conditional logic, such as cancelling runs only if certain matrix parameters are met or if specific environment variables are set.
Third-Party Actions and Legacy Workarounds
Before the native concurrency features became more robust, developers relied on third-party actions like styfle/cancel-workflow-action to manage job cancellation. This action queries the GitHub API to find previous workflow runs that match the current branch but have a different SHA, then cancels them. It specifically targets runs with a status of queued or in_progress.
yaml
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action
While this action provided a solution, it has significant limitations. It requires the workflow to actually be running to execute the cancellation logic. If the pipeline is saturated and runners are unavailable, the action may never execute, leaving orphaned jobs running indefinitely. Additionally, it can only cancel workflows if it is scheduled, which introduces a dependency on runner availability that native concurrency does not have.
The styfle/cancel-workflow-action also supports specific flags for edge cases. The all_but_latest flag allows the action to cancel itself and all later-scheduled workflows, leaving only the latest one. This is useful in scenarios where multiple pushes occur in rapid succession, and only the final state is relevant.
yaml
jobs:
cancel:
name: 'Cancel Previous Runs'
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action
with:
all_but_latest: true
For pull request closures, where the SHA does not change, the ignore_sha option is necessary to cancel runs associated with a closed PR.
yaml
on:
pull_request:
types: [closed]
jobs:
cleanup:
name: 'Cleanup After PR Closed'
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Cancel build runs
uses: styfle/cancel-workflow-action
with:
ignore_sha: true
workflow_id: 479426
However, for most modern workflows, the native concurrency property is the preferred approach. It is more efficient, does not require additional runner time, and is deeply integrated into the GitHub Actions engine.
Protecting Shared Resources and Deployment Integrity
Concurrency controls are not limited to preventing redundant test runs. They are also critical for protecting shared resources, such as databases or production environments, from contention. In these cases, the goal is not to cancel runs, but to queue them, ensuring that only one job accesses the resource at a time.
By setting cancel-in-progress: false, workflows can be forced to wait in a queue. This is particularly important for deployment jobs or database migrations.
yaml
jobs:
database-migration:
runs-on: ubuntu-latest
# Only one migration at a time across all branches
concurrency:
group: database-migration-prod
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Acquire lock
run: |
# Additional application-level locking if needed
./acquire-migration-lock.sh
- name: Run migration
run: npm run migrate
- name: Release lock
if: always()
run: ./release-migration-lock.sh
In this configuration, all workflow runs that share the database-migration-prod group are serialized. If a new migration is triggered while another is in progress, the new run will wait in the queue until the current one completes. This prevents race conditions and ensures data integrity.
For deployment workflows, a combination of queuing and conditional cancellation can be used. Deployments to production should never be cancelled, as this could leave the system in an unstable state. Instead, they should be queued to ensure orderly execution.
yaml
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
# Timeout prevents runs from waiting forever
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- name: Check queue position
run: |
echo "Waiting for previous deployment to complete..."
- name: Deploy
run: ./deploy.sh
This setup ensures that deployments are processed in the order they are received, with a timeout to prevent indefinite waiting if a previous job hangs.
Manual Cancellation and Troubleshooting
Despite the sophistication of automated concurrency controls, there are instances where manual intervention is necessary. GitHub provides a user interface for cancelling workflow runs that are stuck or no longer needed.
To cancel a run manually:
1. Navigate to the main page of the repository on GitHub.
2. Click Actions under the repository name.
3. Select the desired workflow from the left sidebar.
4. From the list of workflow runs, click the name of the queued or in progress run.
5. Click Cancel workflow in the upper-right corner of the workflow view.
Understanding the process GitHub uses to cancel a workflow run is important for troubleshooting. When a run is cancelled, GitHub attempts to stop the current job and free up associated resources. However, if a job is in a state that cannot be interrupted, such as a long-running database transaction, the cancellation may take time or require additional cleanup steps.
Conclusion
Effective management of GitHub Actions workflows requires a nuanced understanding of concurrency controls. While the initial temptation is to universally enable cancel-in-progress to save resources, this approach can compromise the stability of production environments. By leveraging native expression capabilities, developers can create sophisticated concurrency strategies that cancel redundant pull request runs while preserving the integrity of main branch deployments and shared resources. The shift away from third-party actions towards native concurrency features represents a maturation of the GitHub Actions platform, offering more reliable and efficient pipeline management. As workflows become more complex, the ability to finely tune cancellation logic based on event types, branch names, and resource constraints will remain a critical skill for DevOps engineers and developers alike.