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:

  • build is the first job. It checks out code, sets up Node.js, installs dependencies, builds the project, and uploads the dist directory as an artifact named app-build.
  • test has needs: build. This tells GitHub Actions that test cannot start until build has completed. It downloads the artifact from build, reinstalls dependencies (important because the artifact only contains the build output, not the node_modules from the build environment), and then runs tests.
  • deploy has needs: test. It downloads the artifact and then conditionally runs its deployment steps. The if: needs.test.result == 'success' condition is key here. It ensures that the deploy job only proceeds if the test job (and by extension, its dependency build) 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.

Want structured learning?

Take the full Github-actions course →