Chapter 03Lesson 02~130 minutes

Jobs, Stages, Scripts, Images, Services, before_script, after_script, and Exit Behavior: Guided Hands-On Workflow and Core Operations

Turn the Chapter 03 execution model into observable evidence. You will build three small jobs, compare stage ordering, inspect job and runner identity, use a versioned container image and a disposable HTTP service when Docker-capable execution is available, exercise non-zero exit codes, and prove what after_script can and cannot change.

Hands-onDocker imageService containerExit codesafter_script

Learning objectives

  • Create a three-job disposable pipeline and predict its stage ordering before running it.
  • Record pipeline/job IDs, exact SHA, runner identity, executor/image information, logs, and artifacts without exposing credentials.
  • Run or faithfully simulate a Docker image plus HTTP service and verify service access by an explicit hostname alias.
  • Demonstrate one controlled non-zero exit and one after_script observation without hiding the original result.
  • Clean up only the disposable branch/project resources created for the lesson.
Lab scope. Use a disposable GitLab project or disposable branch. The mandatory path needs no secret and no privileged runner. Docker image/service behavior is executed only on an authorized Docker-capable runner; otherwise use the local Docker simulation and retain GitLab lint/compiled-configuration evidence.

1. Preflight: identify source, runner capability, and existing CI state

LAB_BRANCH="ch03/job-execution"
EVIDENCE="ch03-evidence"
mkdir -p "$EVIDENCE"

glab auth status
git status --short --branch
git rev-parse HEAD | tee "$EVIDENCE/head-before.txt"

test ! -e .gitlab-ci.yml || {
  echo "Existing CI configuration detected; use a disposable project/branch."
  exit 2
}

In GitLab, inspect Settings → CI/CD → Runners or the job’s runner metadata. Record only non-secret capability information: whether an eligible runner exists, executor type if visible, and whether Docker-style image/services are appropriate. Never copy runner authentication tokens into evidence.

2. Build three small jobs and predict stage ordering

stages:
  - build
  - test

default:
  before_script:
    - printf 'job=%s stage=%s sha=%s source=%s
' "$CI_JOB_NAME" "$CI_JOB_STAGE" "$CI_COMMIT_SHA" "$CI_PIPELINE_SOURCE"

build_one:
  stage: build
  script:
    - printf 'build_one
' > build-one.txt

build_two:
  stage: build
  script:
    - printf 'build_two
' > build-two.txt

test_stage:
  stage: test
  script:
    - printf 'test_started_after_build_stage
' 

Before execution, predict that both build jobs become eligible together and the test job waits for the build stage. Actual start times still depend on runner capacity. If there is one runner with one concurrent slot, “same stage can run in parallel” does not guarantee simultaneous execution.

3. Add one Docker image + service probe

http_service_probe:
  stage: test
  image: alpine:3.22.1
  services:
    - name: nginx:1.28.0-alpine
      alias: web
  before_script:
    - printf 'runner=%s
' "${CI_RUNNER_DESCRIPTION:-unknown}"
    - printf 'waiting-for-service
'
    - |
      i=0
      until wget -qO- http://web/ >/dev/null 2>&1; do
        i=$((i + 1))
        [ "$i" -lt 20 ] || { echo "service did not become application-ready"; exit 1; }
        sleep 1
      done
  script:
    - wget -qO- http://web/ | head -n 1
  after_script:
    - printf 'after_status=%s
' "$CI_JOB_STATUS" > service-after.txt
  artifacts:
    when: always
    expire_in: 1 day
    paths:
      - service-after.txt

The service alias web is the hostname. Do not use localhost for the service container. The explicit readiness loop checks the HTTP behavior the job actually needs rather than trusting only Runner’s port-level health check.

4. No Docker-capable runner? Reproduce the network boundary locally

docker network create ch03-net

docker run -d --rm --name ch03-web --network ch03-net   nginx:1.28.0-alpine

docker run --rm --network ch03-net   alpine:3.22.1   sh -c 'i=0; until wget -qO- http://ch03-web/ >/dev/null 2>&1; do i=$((i+1)); [ "$i" -lt 20 ] || exit 1; sleep 1; done; wget -qO- http://ch03-web/ | head -n 1'

docker rm -f ch03-web 2>/dev/null || true
docker network rm ch03-net

This local simulation proves container networking and application readiness, not GitLab runner scheduling or GitLab job status. Preserve that limitation explicitly.

5. Observe a controlled non-zero exit without hiding it

Add a temporary job that fails visibly:

exit_probe:
  stage: test
  script:
    - printf 'before failure
'
    - sh -c 'exit 23'
    - printf 'this line should not execute
'
  after_script:
    - printf 'after_status=%s
' "$CI_JOB_STATUS" > exit-after.txt
  artifacts:
    when: always
    paths: [exit-after.txt]

Expected outcome: the main script fails at exit code 23, subsequent main-script commands do not run, after_script executes for a normal script_failure, and the job remains failed. Capture the trace before editing the job.

6. Prove the separate-shell boundary

shell_boundary:
  stage: test
  before_script:
    - export MAIN_ONLY="set-in-main-shell"
    - printf 'workspace-file
' > handoff.txt
  script:
    - test "$MAIN_ONLY" = "set-in-main-shell"
    - test -f handoff.txt
  after_script:
    - test -f handoff.txt
    - test -z "${MAIN_ONLY:-}" || { echo "unexpected shell variable leak"; exit 1; }
    - printf 'separate-shell-confirmed
' > boundary-after.txt
  artifacts:
    when: always
    paths: [handoff.txt, boundary-after.txt]

The file survives because it is in the working tree. The exported shell variable does not. This is a clean, non-secret way to observe the documented execution boundary.

7. Commit only in the disposable context and correlate evidence

git switch -c "$LAB_BRANCH"
git add -- .gitlab-ci.yml
git diff --cached --check
git diff --cached
git commit -m "ch03: observe job execution boundaries"
LAB_SHA="$(git rev-parse HEAD)"
printf '%s
' "$LAB_SHA" | tee "$EVIDENCE/lab-sha.txt"
# Optional authorized side effect only:
# git push -u origin "$LAB_BRANCH"

If the pipeline runs, record pipeline source/ref/SHA, pipeline ID, each job ID/status, runner identity, executor/image/service evidence, the first failing command in exit_probe, and the after_script artifacts. Do not paste secret-valued environment dumps.

8. Challenge: choose the right layer first

Symptom First question Owning layer
Job is pending Is there an eligible runner with capacity/tags? Queue / runner
Job starts, then wget web cannot resolve Does service alias/network match compiled job? Executor / service network
Job exits immediately with command not found Does image contain the required tool/shell? Image / toolchain
Main command fails but after-script artifact exists What was the first non-zero command? Main script; after_script is post-processing
Later-stage job never starts after a failed build Was prior-stage success required? Stage scheduling / failure propagation

9. Cleanup and rollback

Keep logs/evidence until the exercise is complete. Then remove only the disposable branch/project or local Docker resources created for the lab.

docker rm -f ch03-web 2>/dev/null || true
docker network rm ch03-net 2>/dev/null || true

git status --short
# If a disposable remote branch was pushed, verify exact identity before deleting:
git ls-remote --heads origin "$LAB_BRANCH"
# git push origin --delete "$LAB_BRANCH"
Next lesson

Configuration, Design Choices, and Tradeoffs

Use the observed runtime boundaries to choose stage/DAG design, hook scope, image strategy, services, and shell conventions intentionally.

Knowledge check

Why do two build jobs in the same stage sometimes run one after another?

Why does the service lab use web instead of localhost?

What is the safest way to demonstrate a failing command?

Why can after_script see a workspace file but not an exported shell variable?

What does the local Docker fallback fail to prove?

Official references and version notes

  • CI/CD YAML syntax reference — current stages, stage, needs, image, services, before_script, after_script, allow_failure, and timeout semantics.
  • Scripts and job logs — non-zero exit behavior, multiline-command caveats, default hooks, and cancellation behavior.
  • Job execution flow — Runner source preparation, cache/artifact transfer, main execution, after_script, upload, and cleanup phases.
  • Docker executor — job images, service containers, runner workflow, shell requirements, entrypoints, and privilege risks.
  • Run jobs in Docker containers — image/service syntax, entrypoint handling, and where scripts execute.
  • Services — service aliases, networking, health checks, startup warnings, and service lifecycle.
  • Runner executors and supported shells — executor isolation and shell portability boundaries.
Version and compatibility note

Version-sensitive statements were rechecked against current primary GitLab documentation on 2026-09-11. Core jobs, stages, scripts, hooks, images, services, needs, and allow_failure are documented across GitLab Free, Premium, and Ultimate. Docker image/service examples require a Docker-capable runner or the documented local Docker simulation; the mandatory learning path does not require registering or weakening a production runner.

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.