The orchestration of Continuous Integration and Continuous Deployment (CI/CD) pipelines within GitHub Actions relies heavily on the ability to control the execution flow of jobs and steps based on the outcomes of previous operations. Central to this control is the always() function, a job-level and step-level conditional that ensures specific logic is executed regardless of the success or failure of preceding tasks. While superficially simple, the interaction between always(), job dependencies, and the broader GitHub context creates a complex environment where unexpected behavior can emerge, particularly when dealing with skipped jobs and the propagation of status signals across a workflow's directed acyclic graph (DAG).
The Mechanics of the Always Function
The always() function is a status check expression used within the if conditional of a GitHub Actions workflow. Its primary technical purpose is to override the default behavior of the GitHub Actions runner, which normally skips all subsequent steps in a job if any single step fails.
By utilizing if: always(), a developer instructs the runner to execute the designated step even if previous steps have failed or if the job itself was marked for skipping due to a failed dependency. This is critical for "cleanup" operations, such as deleting temporary cloud resources, uploading diagnostic logs, or sending notifications to external communication tools like Slack or Microsoft Teams.
The impact for the end user is the guarantee of execution. Without always(), a failure in a build step would prevent a "Post-Build Report" step from running, leaving the developer with no visibility into why the build failed. By implementing this function, the workflow ensures that the reporting mechanism is triggered, providing the necessary telemetry for debugging.
Contextually, always() operates in tandem with other status check functions such as failure() and success(). While failure() only triggers upon an error, always() is a catch-all that encompasses both success and failure states, making it the most permissive conditional available in the workflow syntax.
Contextual Data Integration and the GitHub Object
To effectively manage the logic that always() triggers, developers must leverage the github context. The github object is the top-level context available during any job or step in a workflow, providing a comprehensive set of metadata about the current execution environment.
The technical layer of the github context allows for granular control over what happens when always() is invoked. For instance, the github.event object contains the full webhook payload of the event that triggered the workflow. This means a step marked with always() can use the github.event data to determine if it should perform a specific action based on whether the trigger was a push or a pull_request.
The following table details the core properties of the github context and their relevance to workflow execution:
| Property name | Type | Description |
|---|---|---|
| github.action | string | The name of the action currently running, or the id of a step. |
| github.actor | string | The username of the user that triggered the initial workflow run. |
| github.actor_id | string | The account ID of the person or app that triggered the initial workflow run. |
| github.api_url | string | The URL of the GitHub REST API. |
| github.base_ref | string | The baseref or target branch of the pull request (available for pullrequest events). |
| github.event | object | The full event webhook payload. |
| github.event_name | string | The name of the event that triggered the workflow run. |
| github.event_path | string | The path to the file on the runner containing the full event webhook payload. |
| github.graphql_url | string | The URL of the GitHub GraphQL API. |
| github.head_ref | string | The head_ref or source branch of the pull request. |
| github.job | string | The job_id of the current job (available only within execution steps). |
| github.ref | string | The fully-formed ref of the branch or tag that triggered the run. |
The impact of this detailed context is that it allows for highly dynamic "always-run" steps. For example, a step that always runs can check github.actor to determine if the person who triggered the workflow has the permissions required to post a comment on a PR via the github.api_url.
Job Dependency Paradoxes and Execution Failures
A significant challenge arises when always() is used at the job level rather than the step level, specifically when jobs are dependent on one another. In a standard GitHub Actions configuration, if job_b depends on job_a (via needs: job_a), and job_a is skipped, then job_b is also skipped by default.
There is a documented discrepancy in how GitHub Actions handles these skipped states. In some scenarios, if a preceding job (e.g., service_a) is skipped, subsequent jobs (e.g., service_b through service_d) may be skipped even if they have conditionals that should technically evaluate to true. This behavior suggests that the "skipped" status of a dependency can act as a dominant signal that overrides standard conditional evaluations, forcing the developer to use if: always() at the job level to ensure the workflow continues.
This creates a poor developer experience because debugging these failures is exceptionally difficult. When a job is skipped due to these internal logic conflicts, enabling ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG may not provide additional logs. The user is often left with a simple "skipped" status in the UI without a detailed trace of why the conditional evaluation returned false when it should have been true.
The real-world consequence is a loss of productivity. Developers may spend hours attempting to resolve job dependencies, only to find that the GitHub Actions engine is behaving in a way that contradicts the official documentation regarding how skipped jobs affect the evaluation of downstream conditionals.
Managing Runner Environments and Resource Constraints
When using always() to handle artifacts or logs, the runner context provides essential pathing information. The runner context allows the workflow to identify where the execution is taking place and where temporary files are stored.
The runner.environment property specifies whether the job is running on a github-hosted runner or a self-hosted runner. This is vital because the file system structure differs between the two. For example, the runner.temp property provides the path to the temporary directory, which is used to write logs that must be preserved even if the main build fails.
Consider a scenario where a build fails. A step using if: ${{ failure() }} or if: always() can use the runner.temp path to locate the build logs and upload them as an artifact using actions/upload-artifact@v4.
Example of utilizing the runner context within a failure/always logic:
yaml
name: Build
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build with logs
run: |
mkdir ${{ runner.temp }}/build_logs
echo "Logs from building" > ${{ runner.temp }}/build_logs/build.logs
exit 1
- name: Upload logs on fail
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: Build failure logs
path: ${{ runner.temp }}/build_logs
The impact of using the runner context is the creation of portable and robust workflows. By referencing ${{ runner.temp }} instead of a hardcoded path like /tmp/, the workflow remains compatible across different OS images and runner types.
Secrets Context and Security Implications
The secrets context provides access to encrypted variables, including the automatically generated secrets.GITHUB_TOKEN. When using always() in a step that interacts with the GitHub API, the GITHUB_TOKEN is often required for authentication.
However, there are strict security constraints regarding the secrets context:
- The
secretscontext is entirely unavailable for composite actions for security reasons. - To use a secret within a composite action, it must be passed explicitly as an input.
- GitHub automatically redacts secrets printed to the log to prevent accidental exposure.
The technical requirement here is that developers must be cautious when using always() in combination with secrets. If a step is designed to always run and prints the github context for debugging purposes, there is a risk of exposing sensitive information if the github.token (which is included in the full context) is not properly handled.
The Challenge of Forced Cancellations and Hanging Workflows
A critical issue arises when always() is used in environments where the external tool being interacted with is unstable. If a step is marked with always(), it will attempt to run even if the workflow has been manually canceled by a user.
In certain cases, if the test management tool or third-party API is down or slow, the step using always() may hang indefinitely. This creates a situation where the "Cancel Workflow" button in the GitHub UI does not immediately terminate the process, or the process continues to consume runner minutes.
For users of private repositories, this has a direct financial impact, as they are billed for the additional minutes consumed by a hanging always() step. This highlights a limitation in the current GitHub Actions architecture: the lack of a "force cancel" mechanism that can override the always() instruction and immediately kill the runner process.
The technical struggle here is that always() tells the runner to ignore the cancellation signal for that specific step's initiation, leading to a state where the workflow is ostensibly "canceled" but still executing the final cleanup tasks.
Analysis of Conditional Evaluation Logic
The interaction between always(), failure(), and success() creates a hierarchy of execution. To understand how to properly implement these, one must analyze the logic flow:
success(): Returns true only if no previous step in the job has failed.failure(): Returns true only if any previous step in the job has failed.always(): Returns true regardless of the status of previous steps.cancelled(): Returns true if the workflow was terminated.
When a developer uses if: always(), they are essentially opting out of the standard failure-propagation model. This is useful for post-mortem analysis but dangerous if the "always" step depends on a file created in a "skipped" step. If the preceding step was skipped, the file will not exist, and the always() step will fail with a "file not found" error, even though the logic told it to run.
The discrepancy noted in community discussions regarding skipped jobs and the always() function suggests that the internal state of the runner's "job status" is not always updated in a way that aligns with the documentation. When a job is skipped, the runner may treat the entire dependency chain as "incomplete" rather than "successful" or "failed," which can lead to downstream jobs being skipped even if they use always().
Summary of Configuration Best Practices
To avoid the pitfalls of unexpected skipping and hanging workflows, the following configurations are recommended:
- Use
always()at the step level rather than the job level to maintain better control over the dependency graph. - Explicitly pass secrets to composite actions as inputs rather than attempting to access the
secretscontext directly. - Use the
runner.tempvariable for all temporary file operations to ensure cross-platform compatibility. - Combine
always()with specific checks of thegithub.eventcontext to ensure that cleanup steps only run for the appropriate trigger types. - Implement timeouts for steps using
always()to prevent the consumption of excessive runner minutes when external APIs are unresponsive.