Chapter 11Lesson 02~220 minutes

Job Dependencies, needs, Concurrency, Cancellation, and Deployment Serialization: Guided Hands-On Workflow

This lab uses a disposable repository and no credentials beyond the automatic read-only job context. You will first prove fan-out/fan-in, then run two overlapping fake deployments that wait instead of collide, and finally compare that behavior with a separate cancel-old CI workflow.

Hands-onParallel jobsqueue: maxCancellationEvidence

Learning objectives

  • Create independent validation jobs and a read-only aggregator that inspects direct dependency results.
  • Use a job-level concurrency group with queue: max to serialize fake deployments without dropping accepted runs.
  • Create overlapping manual runs and capture evidence of running, pending/queued and completed states.
  • Compare queueing with a separate workflow-level cancel-in-progress experiment.
  • Preserve run IDs, attempts, SHAs, job conclusions and first cancellation evidence before cleanup.

1. Preflight: use a disposable repository

Create or select a repository that exists only for this lab. The workflows use permissions: {}, no checkout action, no secrets and no external deployment. Record the default branch and current SHA before adding files.

git status --short
git rev-parse --show-toplevel
git rev-parse HEAD
git branch --show-current
Boundary

Do not test cancellation or concurrency by pointing these examples at a real deployment. The “deployment” below is only a timed log-producing critical section.

2. Workflow A: parallel validation → fan-in → serialized fake deployment

Save this as .github/workflows/ch11-serialized.yml. The validation jobs are independent. The aggregator runs even after a validation failure because it only records evidence. The deployment runs only when both validation results are successful.

name: ch11-serialized
on:
  workflow_dispatch:
    inputs:
      target:
        description: Fake deployment target
        required: true
        type: choice
        options: [staging, qa]
      hold_seconds:
        description: Seconds to hold the fake deployment lock
        required: true
        type: number
        default: 30

permissions: {}

jobs:
  lint:
    runs-on: ubuntu-24.04
    steps:
      - shell: bash
        run: |
          echo "lint run=$GITHUB_RUN_ID sha=$GITHUB_SHA"
          sleep 3
          echo "lint=success"

  test:
    runs-on: ubuntu-24.04
    steps:
      - shell: bash
        run: |
          echo "test run=$GITHUB_RUN_ID sha=$GITHUB_SHA"
          sleep 5
          echo "test=success"

  aggregate:
    if: ${{ always() }}
    needs: [lint, test]
    runs-on: ubuntu-24.04
    outputs:
      ready: ${{ steps.decision.outputs.ready }}
    steps:
      - id: decision
        shell: bash
        run: |
          printf 'lint=%s\n' '${{ needs.lint.result }}'
          printf 'test=%s\n' '${{ needs.test.result }}'
          if [[ '${{ needs.lint.result }}' == success && '${{ needs.test.result }}' == success ]]; then
            echo 'ready=true' >> "$GITHUB_OUTPUT"
          else
            echo 'ready=false' >> "$GITHUB_OUTPUT"
          fi

  deploy:
    needs: [aggregate]
    if: ${{ needs.aggregate.outputs.ready == 'true' }}
    runs-on: ubuntu-24.04
    concurrency:
      group: ch11-deploy-${{ inputs.target }}
      queue: max
    steps:
      - name: Enter serialized fake deployment
        shell: bash
        env:
          HOLD_SECONDS: ${{ inputs.hold_seconds }}
          TARGET: ${{ inputs.target }}
        run: |
          echo "DEPLOY_START run=$GITHUB_RUN_ID target=$TARGET time=$(date -u +%FT%TZ)"
          sleep "$HOLD_SECONDS"
          echo "FAKE_TARGET_STATE target=$TARGET revision=$GITHUB_SHA run=$GITHUB_RUN_ID"
          echo "DEPLOY_END run=$GITHUB_RUN_ID target=$TARGET time=$(date -u +%FT%TZ)"

The concurrency group is scoped to a fake target, not the whole repository. staging and qa can proceed independently; two staging deployments cannot overlap.

3. Predict before dispatch

Write down these predictions before creating any run:

  1. Within one run, lint and test can overlap because neither needs the other.
  2. aggregate starts only after both direct dependencies finish.
  3. deploy becomes eligible only when ready=true.
  4. If two runs target staging, only one deploy job can be active in group ch11-deploy-staging.
  5. With queue: max, the second eligible deploy should wait rather than cancel the active deploy.

4. Create two overlapping runs

Dispatch run A with target=staging and hold_seconds=45. As soon as A’s deploy job starts, dispatch run B with the same target. Use the UI or gh to record both run IDs.

gh workflow run ch11-serialized.yml -f target=staging -F hold_seconds=45
sleep 10
gh workflow run ch11-serialized.yml -f target=staging -F hold_seconds=10
gh run list --workflow ch11-serialized.yml --limit 5

Do not rely on dispatch order alone. Record when each deploy job actually began waiting on the group and when it entered the critical section.

5. Prove serialization from timestamps

For both runs, capture job status and logs. The critical evidence is that the second DEPLOY_START is not earlier than the first DEPLOY_END.

gh run view RUN_A --json databaseId,attempt,headSha,status,conclusion,jobs
gh run view RUN_B --json databaseId,attempt,headSha,status,conclusion,jobs
# After completion:
gh run view RUN_A --log | grep -E 'DEPLOY_(START|END)|FAKE_TARGET_STATE'
gh run view RUN_B --log | grep -E 'DEPLOY_(START|END)|FAKE_TARGET_STATE'

A green result alone is insufficient. Preserve the two run IDs, both SHAs, deploy job timestamps and resolved target/group value.

6. Negative control: different targets should not block one another

Run one staging and one qa deployment with the same hold time. Their groups resolve to different values, so overlap is allowed. This proves the lock is scoped to the delivery invariant rather than globally serializing all deployments.

7. Workflow B: safe cancel-old experiment

Save a second workflow as .github/workflows/ch11-cancel-old.yml. It represents disposable validation, not deployment.

name: ch11-cancel-old
on:
  workflow_dispatch:
    inputs:
      hold_seconds:
        required: true
        type: number
        default: 45

permissions: {}

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  stale-safe-work:
    runs-on: ubuntu-24.04
    steps:
      - shell: bash
        env:
          HOLD_SECONDS: ${{ inputs.hold_seconds }}
        run: |
          echo "START run=$GITHUB_RUN_ID attempt=$GITHUB_RUN_ATTEMPT"
          sleep "$HOLD_SECONDS"
          echo "END run=$GITHUB_RUN_ID"

Dispatch A, then B before A finishes. Preserve A’s cancellation conclusion and first log. The lesson is not “cancellation is good”; it is “cancellation is safe here because the job has no external side effect.”

8. Interpret cancellation causally

The old run may show a canceled job and an interrupted sleep. GitHub re-evaluates conditions, signals the runner process, escalates termination if needed and eventually enforces a cancellation timeout. Do not treat a 403, runner failure or deployment error as “just cancellation” unless the run/job status supports that conclusion.

9. Challenge: choose the correct layer

A team says: “Only one production deployment may execute at a time, but every approved release must eventually deploy. CI for feature branches should cancel stale runs.” Choose a design without copying the examples verbatim.

Reveal one defensible design

Use job-level production concurrency with a production-specific group and queue: max for the deployment job. Use a separate workflow-level branch-scoped group with cancel-in-progress: true for disposable feature CI. Do not use the same group for both policies.

10. Cleanup

Delete the disposable workflows/repository when finished. No external target or secret was created. Preserve the run IDs/log excerpts needed for your evidence packet before deleting the lab repository.

Knowledge check

Why does aggregate use always() while deploy does not?

Two staging deploy jobs have the same group and queue: max. What should the second do?

Why run staging and qa simultaneously as a negative control?

What does the canceled validation experiment prove?

What evidence proves serialization better than two green runs?

Next chapter concept

Design the policy before the YAML

Lesson 3 compares dependency and concurrency patterns by delivery invariant, trust boundary, rollback and audit cost.

Official references and version notes

Version and compatibility note

Version-sensitive behavior was rechecked against current GitHub-maintained documentation on 2026-09-09. Current documentation states that needs contains only direct dependencies and exposes result as success, failure, cancelled or skipped. A failed or skipped dependency normally skips downstream jobs unless an explicit job condition permits continuation. Concurrency groups are repository-wide and case-insensitive. By default at most one item may be running and one pending in a group; a newer pending item replaces an older pending item. Current GitHub Actions also supports queue: max to allow up to 100 pending items, processed FIFO by time waiting on the group; this mode cannot be combined with cancel-in-progress: true. Cancellation re-evaluates job/step conditions, so always() can keep work running during cancellation and must not be used casually for privileged or irreversible operations. Mandatory labs use ubuntu-24.04, permissions: {}, no Marketplace action and no real credential.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.