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.
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_scriptobservation without hiding the original result. - Clean up only the disposable branch/project resources created for the lesson.
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"
Knowledge check
Why do two build jobs in the same stage sometimes run one after another?
Stage semantics allow parallelism, but actual concurrency depends on available eligible runners and their concurrency settings.
Why does the service lab use web instead of
localhost?
The service is a separate container on the job network; its configured alias becomes the network hostname.
What is the safest way to demonstrate a failing command?
Let it fail visibly, capture the original trace/status, and
avoid blanket error suppression such as || true.
Why can after_script see a workspace file but not
an exported shell variable?
It starts a new shell with the working directory reset appropriately; filesystem changes in the project tree persist, shell-process state does not.
What does the local Docker fallback fail to prove?
It does not prove GitLab pipeline creation, runner selection, job IDs/status, or GitLab artifact upload behavior.
Official references and version notes
-
CI/CD YAML syntax reference
— current
stages,stage,needs,image,services,before_script,after_script,allow_failure, andtimeoutsemantics. - 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-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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.