You can chain GitHub Actions jobs using the needs keyword, but the most surprising thing is that it doesn’t actually chain them in the way most people expect. It’s not a strict, sequential pipeline where Job B waits for Job A to finish successfully. Instead, needs declares a dependency graph, and GitHub Actions orchestrates the execution order based on that graph, but it allows for significant parallelism and doesn’t enforce strict success propagation by default.
Let’s see this in action. Imagine you have a workflow that first builds your application, then runs tests, and finally deploys if both succeed.
name: CI/CD Pipeline
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: app-build
path: ./dist
test:
runs-on: ubuntu-latest
needs: build # This job depends on the 'build' job
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: app-build
- name: Install dependencies
run: npm ci # Need to reinstall for testing environment
- name: Run tests
run: npm test
deploy:
runs-on: ubuntu-latest
needs: test # This job depends on the 'test' job
steps:
- uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: app-build
- name: Deploy to production
run: echo "Deploying to production..."
# In a real scenario, this would be your deployment script
# with necessary credentials and logic.
# The 'if' condition is crucial for conditional execution
if: needs.test.result == 'success'
In this workflow:
buildis the first job. It checks out code, sets up Node.js, installs dependencies, builds the project, and uploads thedistdirectory as an artifact namedapp-build.testhasneeds: build. This tells GitHub Actions thattestcannot start untilbuildhas completed. It downloads the artifact frombuild, reinstalls dependencies (important because the artifact only contains the build output, not thenode_modulesfrom the build environment), and then runs tests.deployhasneeds: test. It downloads the artifact and then conditionally runs its deployment steps. Theif: needs.test.result == 'success'condition is key here. It ensures that thedeployjob only proceeds if thetestjob (and by extension, its dependencybuild) completed successfully.
The needs keyword creates a directed acyclic graph (DAG) of jobs. GitHub Actions resolves this graph and schedules jobs to run as soon as their dependencies are met. This means if you had a lint job that also needed build, both test and lint could potentially run in parallel after build finishes, as they don’t depend on each other.
The needs keyword isn’t just for sequential execution; it’s for defining any dependency relationship. A job can depend on multiple other jobs. For example:
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- run: echo "Building frontend..."
build-backend:
runs-on: ubuntu-latest
steps:
- run: echo "Building backend..."
package:
runs-on: ubuntu-latest
needs: [build-frontend, build-backend] # Depends on BOTH
steps:
- run: echo "Packaging everything..."
In this case, the package job will only start after both build-frontend and build-backend have successfully completed.
The result property on a needs object is a powerful tool for controlling flow. needs.job_name.result can be 'success', 'failure', 'cancelled', or 'skipped'. This allows you to create complex conditional logic. For instance, you might want to run a cleanup job if any preceding job fails:
jobs:
job-a:
runs-on: ubuntu-latest
steps:
- run: exit 1 # This job will fail
job-b:
runs-on: ubuntu-latest
needs: job-a
steps:
- run: echo "This won't run if job-a fails."
cleanup:
runs-on: ubuntu-latest
needs: job-a # Depends on job-a
steps:
- run: echo "Cleaning up resources..."
# Run cleanup if job-a failed or was cancelled
if: needs.job-a.result == 'failure' || needs.job-a.result == 'cancelled'
When you use needs, you’re not just defining a sequence; you’re defining a set of prerequisites. GitHub Actions’ runner allocation and scheduling logic will then execute jobs as soon as their prerequisites are satisfied. This can lead to faster overall workflow execution because independent jobs can run in parallel. The if conditional, especially using needs.<job_id>.result, is how you regain control over the semantic flow (e.g., only deploy if tests passed), rather than relying solely on the execution order dictated by needs.
The most subtle aspect of needs is how it interacts with job failures and cancellations. By default, if a job listed in needs fails, the jobs that depend on it will be marked as skipped unless you explicitly use the if condition to override this behavior. This automatic skipping is a safety mechanism to prevent downstream operations (like deployment) from running on potentially broken code. However, it means that if you want a "cleanup" or "notification" job to run even on failure, you must use an if condition that checks for 'failure' or 'cancelled' status.
The next concept you’ll likely explore is how to pass data between jobs using artifacts, which is essential when needs implies a logical sequence of operations where one job produces output needed by another.