Mert Özen Further With Every Line
DevOps GitHub Actions — #04

Jobs in GitHub Actions: needs, matrix, concurrency, timeouts and Service Containers

Mert Özen Nov 8, 2026 11 min 5 views
Jobs in GitHub Actions: needs, matrix, concurrency, timeouts and Service Containers

Jobs run in parallel by default; needs builds a DAG, and everything depending on a failed job is skipped. We split the Bookstore CI into format, unit-test (OS matrix), integration-test (PostgreSQL 16/17/18 service container), web-build and publish jobs and run it with act, covering include/exclude, fail-fast, max-parallel, concurrency, timeout-minutes, continue-on-error and the service command/entrypoint keys from April 2026.

Version note: The examples in this post were tested with act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10 (SDK 10.0.403), PostgreSQL 16/17/18 (alpine images), Node 24 and Angular 22. The workflows were run locally with act; they were not run on GitHub. The information is current as of November 2026.

Guess First

Bookstore's CI (Continuous Integration) is no longer a single job but a five-job graph: format, unit-test, integration-test, web-build and, at the end of all of them, publish. The dependencies look like this:

jobs:
  format:
    # ...
  unit-test:
    needs: format
  integration-test:
    needs: unit-test
  web-build:
    # needs yok
  publish:
    needs: [integration-test, web-build]

Ada once again changes the shipping fee and forgets to update the tests, and unit-test turns red. The question: does web-build run? What happens to integration-test and publish, do they show up as "failed" or "skipped"?

$ act push --matrix os:ubuntu-latest
[ci/web-build] ⭐ Run Main npm run build
[ci/web-build] 🏁  Job succeeded
[ci/format   ] 🏁  Job succeeded
[ci/unit-test] ⭐ Run Main dotnet test --project api/tests/Bookstore.Api.Tests -c Release
[ci/unit-test]   |   Expected: 29.90
[ci/unit-test]   |   Actual:   24.90
[ci/unit-test]   | Test run summary: Failed!
[ci/unit-test]   ❌  Failure - Main dotnet test --project api/tests/Bookstore.Api.Tests -c Release [5.374225834s]
[ci/unit-test] 🏁  Job failed
Error: Job 'unit-test' failed

web-build ran and finished green, because it depends on nobody; it started at the same time as format. integration-test and publish never started. act stops the run at the failed job here; GitHub shows those jobs as "skipped". The documented rule: if a job fails or is skipped, every job that depends on it through needs is skipped too, and this spreads down the chain from that point on. So publish shows up as "skipped", not "failed"; that matters if you picked publish as a required check in branch protection. In this post we look at the tools for turning jobs into a graph: needs, matrix, service containers, concurrency and timeouts.

The Job Graph: needs and Job Outputs

Jobs in a workflow run in parallel by default; needs makes a job wait for other jobs to finish successfully. The result is a DAG (Directed Acyclic Graph). The graph of the Bookstore CI:

04-job-graph-en.png

act shows the same graph split into stages:

$ act -l
Stage  Job ID            Job name          Workflow name  Workflow file  Events
0      web-build         web-build         ci             ci.yml         push,pull_request
0      format            format            ci             ci.yml         push,pull_request
1      unit-test         unit-test         ci             ci.yml         pull_request,push
2      integration-test  integration-test  ci             ci.yml         pull_request,push
3      publish           publish           ci             ci.yml         push,pull_request

web-build and format are in the same stage, so they start at the same time. The Angular build does not have to wait for the .NET tests; that parallelism directly shortens the total time. publish waits for both branches. The question to ask when designing the graph is: "Does this job really need the result of the previous one, or did I line them up out of habit?" format is a cheap, fast check, so it goes first; there is no point spinning up a ten-minute test machine for a commit with broken formatting.

Small values move between jobs through outputs. The publish job produces a version number and exposes it as a job output:

  publish:
    needs: [integration-test, web-build]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - id: version
        run: echo "version=1.4.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
      - run: echo "would publish Bookstore ${{ steps.version.outputs.version }}"

Another job connected to it through needs (a deploy, say) reads the value as needs.publish.outputs.version. An important rule: a job can only see the outputs of jobs named in its own needs; you cannot directly reach the output of a job two links back in the chain, the job in between has to carry it as its own output.

Matrix: Combinations, include, exclude, fail-fast and max-parallel

A matrix runs the same job with different parameters. Bookstore has two matrices. The first runs the unit tests on two operating systems:

  unit-test:
    needs: format
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - run: dotnet test --project api/tests/Bookstore.Api.Tests -c Release

The second runs the integration tests against three PostgreSQL versions and marks 18 as "experimental":

  integration-test:
    needs: unit-test
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      max-parallel: 1
      matrix:
        postgres: ['16', '17']
        include:
          - postgres: '18'
            experimental: true
    continue-on-error: ${{ matrix.experimental == true }}

include does two things: it adds a new key to an existing combination, or, if it matches no combination, it creates a new one. Here postgres: '18' matches none of the existing values, so a third job was born and only that one got experimental: true. exclude does the opposite and removes specific combinations; for example, skipping an old version on Windows in an os × dotnet matrix.

fail-fast defaults to true: when one job in the matrix fails, GitHub cancels the other running and queued jobs of the same matrix. That is good for fast feedback, but it also kills the answer to "is it broken only on Windows, or everywhere?"; that is why we set it to false. max-parallel: 1 runs the PostgreSQL jobs one after another. On GitHub each job is a separate VM (Virtual Machine), so this is unnecessary; in act, however, all three jobs would ask for the same 18232 port on the same Docker host. act really honored it: in the log integration-test-1, -2 and -3 started one after another.

According to the documentation a matrix can produce at most 256 jobs per workflow run. The real limit is budget: two OSes (Operating Systems) × three .NET versions × three databases is 18 jobs, and each one spends minutes on its own.

Two notes on the act side. First, act can only run Linux containers, so it skipped the windows-latest combination: Skipping unsupported platform -- Try running with `-P windows-latest=...`. Second, with an unsupported platform in the same matrix, act 0.2.97 mixed up the image assignment: in one run the ubuntu-latest job died with an empty image name (invalid reference format), in another attempt the windows-latest combination ran on the Ubuntu image. So we ran act with --matrix os:ubuntu-latest, narrowing the matrix to the Linux combination only. We did not verify the Windows side in this post.

Integration Tests with a Service Container: PostgreSQL

Our integration tests want a real PostgreSQL. Instead of installing PostgreSQL on the runner, we add a service container to the job; GitHub starts the container before the job and removes it when the job finishes:

    services:
      postgres:
        image: postgres:${{ matrix.postgres }}-alpine
        env:
          POSTGRES_DB: bookstore
          POSTGRES_USER: bookstore
          POSTGRES_HOST_AUTH_METHOD: trust
        ports:
          - 18232:5432
        options: >-
          --health-cmd "pg_isready -U bookstore -d bookstore"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      ConnectionStrings__Bookstore: Host=localhost;Port=18232;Database=bookstore;Username=bookstore
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - name: Integration tests (PostgreSQL ${{ matrix.postgres }})
        run: dotnet test --project api/tests/Bookstore.Api.IntegrationTests -c Release

Three details matter. Without the health check under options, the runner may move on to the steps before PostgreSQL accepts connections, and the first test fails with "connection refused"; with the health check, the runner waits until the container is healthy. The ports line is needed because the job runs on the runner itself: the job and the service are in different places, and the bridge between them is the port on the host. Had you run the job in a container with container:, you would reach the service as postgres:5432, without a port mapping. Finally, POSTGRES_HOST_AUTH_METHOD: trust allows connections without a password; since the container only lives for the duration of this job, that is acceptable in CI and nowhere else.

We wrote 18232 as the host port instead of 5432 because running the workflow with act on this machine needed a port that would not clash with other work; on a GitHub-hosted runner 5432:5432 works fine too. If you don't want to fix the host port at all, write - 5432/tcp and read the assigned port with job.services.postgres.ports['5432'].

The service's life cycle is clearly visible in the act log:

[ci/integration-test-1]   🐳  docker create image=postgres:16-alpine platform=linux/arm64 entrypoint=[] cmd=[] network="act-ci-integration-test-1-dc84ae659901e21be92d4b0742ae7d96d7079881b0ec547bf1201e01a03a9784-integra…
[ci/integration-test-1] container health of 358a678e7b9017776d6ff6f31cb6f9d5f168e6395ef7d50beb0a81a69b3501bf (postgres:16-alpine) is starting
[ci/integration-test-1] container health of 358a678e7b9017776d6ff6f31cb6f9d5f168e6395ef7d50beb0a81a69b3501bf (postgres:16-alpine) is healthy
[ci/integration-test-1]   ✅  Success - Set up job
[ci/integration-test-1] 🧪  Matrix: map[postgres:16]
...
[ci/integration-test-1] ⭐ Run Main Integration tests (PostgreSQL 16)
[ci/integration-test-1]   | Test run summary: Passed!
[ci/integration-test-1]   |   total: 3
[ci/integration-test-1]   |   failed: 0
[ci/integration-test-1]   |   succeeded: 3
[ci/integration-test-1]   ✅  Success - Main Integration tests (PostgreSQL 16) [7.244132334s]
...
[ci/integration-test-1] Cleaning up services for job integration-test
[ci/integration-test-1] 🏁  Job succeeded

The tests bring the API (Application Programming Interface) up in memory with WebApplicationFactory, create the schema and seed data with EnsureCreated, and run three scenarios against real SQL (Structured Query Language). The ASP.NET Core Web API series post "Integration Testing an ASP.NET Core Web API: End-to-End Tests with WebApplicationFactory" covers this testing approach in detail; here we only built the CI side.

April 2026: entrypoint and command on Service Containers

The April 2, 2026 changelog brought entrypoint and command keys to service containers, as in Docker Compose. For example, starting PostgreSQL with faster but non-durable settings for tests:

    services:
      postgres:
        image: postgres:17-alpine
        command: postgres -c fsync=off -c synchronous_commit=off

Before, you had to build a custom image or try roundabout tricks in options. The tools have not caught up yet, though: when we added the command line, both objected.

$ actionlint
.github/workflows/ci.yml:55:9: unexpected key "command" for "services" section. expected one of "credentials", "env", "image", "options", "ports", "volumes" [syntax-check]
$ act -l
Error: workflow is not valid. 'ci.yml': Line: 40 Column 5: Failed to match job-factory: Line: 54 Column 9: Failed to match non-empty-string: Line: 54 Column 9: Expected a scalar got mapping
Line: 54 Column 9: Failed to match container-mapping: Line: 55 Column 9: Unknown Property command

actionlint 1.7.12 was released on March 30, 2026, three days before the feature; act 0.2.97 does not know the key either. That is why the workflow we ran locally has no command. If you use this feature on GitHub, expect a warning from actionlint for that line for now.

concurrency: Cancelling Old Runs

If you push three commits to a pull request within five minutes, by default three full CI runs execute and nobody cares about the results of the first two. concurrency solves that:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

When a new run starts in the same group and cancel-in-progress is true, the older running one is cancelled. Putting the workflow name into the group is essential; without it you also cancel runs of another workflow on the same branch. github.event.pull_request.number || github.ref is a fallback that drops to the ref when there is no pull request. We turned cancel-in-progress on only for pull requests: we want to see the CI result of every commit on main, and a half-finished main build makes it harder to find which commit broke things.

Two more details: group names are case-insensitive, and by default at most one run can wait as "pending" in a group; when a new one arrives, the waiting one is cancelled. The queue: max option recently added to the documentation lets up to 100 runs wait in line; it is meant for work such as deploys where "none may be skipped, but they must not run at the same time either". queue: max and cancel-in-progress: true cannot be combined. Because act runs a single run at a time, you cannot observe the effect of concurrency locally; actionlint does validate the syntax.

timeout-minutes and continue-on-error

We gave every job a timeout-minutes. The reason is the default: according to the documentation, a job without a timeout can run for 360 minutes, six hours. A test stuck in watch mode or a step waiting for an unresponsive service quietly eats six hours of minutes in a private repository. The rule is simple: two or three times its normal duration. In Bookstore that is 10 minutes for format, 15 for unit tests, 20 for integration tests. There is a step-level timeout-minutes too, for limiting a single slow step.

continue-on-error exists at two levels and does different things. At job level, as in our PostgreSQL 18 example, it keeps the workflow run from counting as failed even if that job fails. At step level it lets the job continue even if the step fails; the outcome/conclusion difference from the third post comes from here. Use both carefully: a job added with an "experimental" label that stays red for months turns into noise nobody looks at.

Production Notes

Running this whole graph in act (except Windows) took 11.5 minutes locally; the machine was busy with other work at the time, so look at the proportions rather than the absolute number. The longest path is format → unit-test → integration-test × 3 → publish; web-build finished in parallel during that time and added nothing to the total. The same logic holds on GitHub: the total duration is the duration of the longest path (the critical path), while the minutes spent are the sum of all jobs. Growing the matrix grows the bill, not the duration; lengthening the chain grows the duration.

To measure, note each job's duration for a few weeks. If a job is always on the critical path, consider splitting it (separating unit and integration, for example) or speeding it up with caching; in the next post we add the NuGet cache. Also question whether every matrix combination actually catches something: three PostgreSQL versions only make sense if production runs more than one version or you are preparing an upgrade.

Common Pitfalls

Matrix combination explosion. os: [ubuntu, windows, macos] × dotnet: [8, 9, 10] × db: [16, 17, 18] makes 27 jobs. macOS runner minutes cost more than Linux ones. Justify each dimension on its own, drop the rest with exclude, or move them to a workflow that only runs at night.

The default 360-minute timeout. Every job without timeout-minutes waits six hours when it hangs.

A service without a health check. The few seconds between PostgreSQL "started" and "accepting connections" are the most common cause of intermittent, unreproducible test failures.

A summary job skipped along with a broken needs chain. If a report or notification job must run in every case, it needs if: ${{ !cancelled() }}; otherwise the first failure in the chain skips it as well.

Writing a concurrency group without the workflow name. group: ${{ github.ref }} puts all workflows on the same branch into one group and makes them cancel each other.

A Small Experiment

★☆☆ Add a line to the integration-test matrix that removes PostgreSQL 16 with exclude, and see how the job count changes in the act -l output.

★★☆ Break unit-test on purpose, run it with act first with fail-fast set to true and then to false, and compare what the other matrix jobs do.

★★★ Add a report job that depends on publish: it should run in every case, write the needs.<job>.result values to the job summary as a table, and show publish.outputs.version too.

We left the .NET jobs in this graph in their simplest form for now: every job downloads the SDK and NuGet packages from scratch, warnings pass silently, and there is no test report or coverage. In the next post we build api-ci.yml: global.json, lock files and --locked-mode, the NuGet cache, warnings as errors, Microsoft.Testing.Platform, TRX (Visual Studio Test Results) and Cobertura reports, and upload-artifact@v7.