GitHub Actions Job Dependencies and Outputs

Jobs in one workflow start at the same time by default. Real pipelines need order: build before test, test before deploy. Dependencies set that order, and outputs pass data from one job to the next.

Parallel Jobs by Default

Each job runs on its own fresh machine. Two jobs without any link start together and finish independently.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Linting"
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing"
Start
  +--- lint ---+
  |            |   both run together
  +--- test ---+

The needs Keyword

The needs keyword makes a job wait for another job to succeed. Write the name of the job that must finish first.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building"

  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing"

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying"
build  --->  test  --->  deploy

When build fails, GitHub skips test and deploy automatically. A broken build never reaches production.

Waiting for Several Jobs

A list under needs makes a job wait for all listed jobs. This pattern is called fan-out and fan-in.

deploy:
  needs: [ unit-tests, ui-tests, security-scan ]
            +--- unit-tests ----+
build ---->  +--- ui-tests ------+----> deploy
            +--- security-scan -+
   fan-out                        fan-in

Why Outputs Exist

Separate machines share nothing. A file or variable created in one job does not exist in the next. Outputs carry small pieces of text, such as a version number, across that gap. Use artifacts for files.

Writing an Output

A step writes a name and value to a special file called GITHUB_OUTPUT. The job then exposes that value under its own outputs block.

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      app_version: ${{ steps.ver.outputs.version }}
    steps:
      - name: Set version
        id: ver
        run: echo "version=1.4.2" >> "$GITHUB_OUTPUT"

The step needs an id so the job can find it. The job output refers to the step through steps.ver.outputs.version.

Reading an Output

A later job reads the value through the needs context. The job must list the producer under needs.

  announce:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Shipping version ${{ needs.build.outputs.app_version }}"

Data Flow Diagram

Job: build                              Job: announce
+--------------------------+            +---------------------------+
| step id=ver              |            | reads                     |
| writes version=1.4.2     |            | needs.build.outputs.      |
|            |             |            | app_version               |
|            v             |            |            ^              |
| job outputs.app_version -+---------->-+------------+              |
+--------------------------+            +---------------------------+

Running a Job After a Failure

A job with needs normally skips when its parent fails. A cleanup or notification job often must run anyway. Add a status check function to the if line.

  notify:
    needs: [ build, test ]
    if: ${{ always() }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Pipeline finished with status ${{ needs.test.result }}"

Each needed job exposes a result value: success, failure, cancelled, or skipped.

Good Habits

  • Keep outputs small. Store long data in artifacts.
  • Name outputs after the data they hold, such as image_tag.
  • Avoid secrets in outputs, since GitHub may block or mask them.
  • Draw the job graph on paper before writing the file. The diagram maps directly to needs lines.

The Actions tab shows a visual graph of every run. Boxes connected by lines match your needs settings, which makes problems in the order easy to spot.