Jobs, Stages, Scripts, Images, Services, before_script, after_script, and Exit Behavior: Concepts, Architecture, and Mental Model
A compiled GitLab job is still only a plan until a runner accepts it
and creates an execution environment. This lesson follows the job
from stage/DAG eligibility through runner assignment, executor
preparation, workspace and service creation,
before_script, script,
after_script, exit status, and retained evidence so
learners can identify exactly which boundary produced a result.
Learning objectives
-
Trace a compiled job from stage/DAG eligibility through runner
assignment, executor preparation, workspace setup, main script
execution,
after_script, and evidence upload. - Explain default stage ordering and why jobs in one stage can execute in parallel while later stages normally wait for prior-stage success.
- Distinguish runner identity, executor type, job image, service containers, workspace state, and GitLab-hosted job metadata.
-
Explain the execution relationship among
before_script,script, andafter_script, including the separate-shell boundary ofafter_script. -
Interpret non-zero exit codes,
allow_failure, cancellation, timeouts, and artifacts without treating one status as proof of unrelated external health.
1. Why “the job ran” is too vague
Chapter 02 ended at compiled configuration and materialized jobs. Chapter 03 begins where execution starts. Operationally, the phrase “the job ran” hides several independent transitions: the job became eligible, waited in a queue, matched a runner, received an executor environment, obtained source and prior artifacts, ran setup and main commands, ran cleanup/post-processing, uploaded evidence, and reported a final status.
When those transitions are collapsed, teams troubleshoot the wrong
layer. A pending job is usually not fixed by editing
shell syntax. A job that reaches running and exits 127
is not primarily a pipeline-creation problem. A service that exposes
a TCP port but is not application-ready requires different evidence
from a missing runner.
2. The job execution model
flowchart TD
A[Compiled job in pipeline graph] --> B[Stage or needs eligibility]
B --> C[Queue and runner selection]
C --> D[Executor prepares workspace / container / pod]
D --> E[Source + cache + prior artifacts]
E --> F[Main execution: before_script then script]
F --> G[after_script in separate shell]
G --> H[Artifacts / cache upload + cleanup]
H --> I[Final job status + retained evidence]
The model deliberately includes Runner work that is not written in
.gitlab-ci.yml. Source preparation, cache/artifact
transfer, executor setup, and upload/cleanup are Runner phases
around your commands. The main execution step contains
before_script and script;
after_script is a distinct later step and uses a new
shell context.
3. Stage ordering is a scheduling rule, not a machine boundary
By default, GitLab groups jobs into stages. Jobs in the same stage
are eligible to run in parallel when runner capacity permits. Jobs
in a later stage normally wait until jobs in the previous stage
complete successfully. A job without an explicit
stage uses test.
stages:
- build
- test
build_linux:
stage: build
script: echo "build linux"
build_docs:
stage: build
script: echo "build docs"
unit_test:
stage: test
script: echo "test after build stage"
build_linux and build_docs belong to one
scheduling stage; they do not share a filesystem just because they
share a stage. Chapter 09 explores DAG design deeply. For now, know
that needs can let a job start as soon as its declared
dependencies finish rather than waiting for the whole previous
stage.
4. Job, runner, executor, image, service, and workspace are different objects
| Object | What it controls | Evidence to inspect |
|---|---|---|
| Job | One executable unit in a pipeline, with its own status/log. | Job ID/name, stage, dependencies, variables, trace. |
| Runner | Agent that requests and executes eligible jobs. | Runner ID/description/version/tags and job assignment. |
| Executor | How the runner creates the execution environment. | Docker, Kubernetes, shell, etc.; runner/job metadata. |
| Image | Container filesystem/tooling for a Docker/Kubernetes-style job. | Image name/tag/digest where exposed; tool versions inside job. |
| Service | Additional job-local network container such as a database or HTTP server. | Service image/alias, Runner service logs/warnings, network checks. |
| Workspace | Checked-out project directory and job-local files. |
CI_PROJECT_DIR, checkout SHA, generated files.
|
The shell executor executes directly on the runner host and has limited isolation; container-oriented executors create a stronger boundary but do not make privileged configuration safe by default. Treat executor choice as security state, not cosmetic syntax.
5. before_script, script, and after_script do not have identical semantics
before_script prepares the main job work, and
script performs the primary task. GitLab Runner
executes the main phase before moving to after_script.
The important boundary is that after_script runs in a
new shell. Shell variables exported during the main
phase, aliases, and changes outside the working tree are not
automatically available there.
job:
before_script:
- export MAIN_PHASE_ONLY="visible-here"
- printf 'prepared
' > prepared.txt
script:
- test "$MAIN_PHASE_ONLY" = "visible-here"
- printf 'main-complete
' > result.txt
after_script:
- test -f result.txt
- printf 'after-script-ran
' > after.txt
Files inside the project working tree are a useful handoff between
phases. Environment mutations are not.
after_script also has its own timeout, and a failing
after_script does not change the job exit code when the
main script succeeded. That makes it useful for post-processing and
evidence, not for “repairing” a failed build result.
6. Exit status is control flow
For normal script commands, a non-zero exit causes the job to fail and later script commands do not execute. That behavior is valuable because it keeps failures visible. Suppressing a non-zero exit should therefore be explicit and justified.
strict_check:
script:
- printf 'about to test
'
- test -f required.txt
- printf 'only runs when test succeeded
'
If a non-zero code is expected and genuinely non-fatal, capture it and decide:
controlled_check:
script:
- rc=0
- optional_tool --probe || rc=$?
- printf 'probe_exit=%s
' "$rc"
- if [ "$rc" -ne 0 ]; then printf 'probe unavailable; continuing by policy
'; fi
|| true as a generic repair. It
destroys the signal that a command failed unless you also capture,
classify, and report the error deliberately.
7. Images and services define runtime dependencies
With the Docker executor, the job image is the
container in which job commands run. services are
separate containers reachable over the job network. The job image
must provide a supported shell in PATH; GitLab also
documents grep as a requirement for Docker executor job
images. An image with an incompatible entrypoint can prevent Runner
from delivering the generated script unless the entrypoint is
designed for shell commands or explicitly overridden.
service_probe:
image: alpine:3.22.1
services:
- name: nginx:1.28.0-alpine
alias: web
script:
- wget -qO- http://web/ | head -n 1
Services are job-local dependencies, not production deployments. Runner performs a port-based service health check, but an open port is not equivalent to application readiness. Robust jobs still perform the application-specific readiness check they actually require.
8. Read-only inspection before rerun
CI_PIPELINE_SOURCE, ref, exact
CI_COMMIT_SHA.
Pipeline ID, job ID/name, stage, status and timestamps.
Runner ID/description/version, executor and tags where visible.
Image identity, service aliases, working directory, safe tool versions.
First non-zero command or Runner/service diagnostic before retry.
Artifacts/reports uploaded after the execution phases.
Preserve the first failing trace before retrying because a rerun can land on a different runner, pull a different mutable image, observe a recovered network dependency, or repeat an external side effect.
9. Foundation mistakes to eliminate now
- “Same stage means same machine.” Stage is scheduling metadata; each job has its own execution environment.
- “The image defines the whole pipeline.” It defines a job runtime only for executors that use job images.
- “Service health warning means the job must fail.” Runner can warn while the job later succeeds; inspect what the application actually required.
- “after_script can change a failed job to success.” It is post-processing in a separate shell, not a status override mechanism.
- “A cleanup command should always ignore errors.” Decide whether cleanup failure is informational or operationally important and preserve the evidence.
- “Green job means deployment is healthy.” It proves only the configured job execution result.
10. Micro-lab: predict the job before running it
stages: [build, test]
default:
before_script:
- printf 'job=%s stage=%s sha=%s
' "$CI_JOB_NAME" "$CI_JOB_STAGE" "$CI_COMMIT_SHA"
build_marker:
stage: build
script:
- printf 'built
' > marker.txt
artifacts:
paths: [marker.txt]
verify_marker:
stage: test
script:
- test -s marker.txt
after_script:
- printf 'after=%s
' "$CI_JOB_STATUS" > after.txt
artifacts:
when: always
paths: [after.txt]
Predict: verify_marker cannot become stage-eligible
until the build stage succeeds; its job environment is separate; the
earlier-stage artifact is the deliberate cross-job file transfer;
after.txt can be captured because
after_script runs before artifact upload; and the
cleanup file does not redefine the main result.
Knowledge check
A job is pending. Should you debug its shell
command first?
No. The job has not begun script execution. Inspect runner eligibility/capacity and scheduling evidence first.
Do two jobs in the same stage share their workspace?
No. Stage membership controls scheduling, not filesystem sharing. Use explicit artifacts or another data-transfer mechanism when needed.
Why can a variable exported in script disappear in
after_script?
after_script runs in a new shell context, so shell
exports and aliases from the main phase are not inherited.
What does a Runner service health check prove?
Only that Runner observed service port accessibility within its health-check model. It does not prove the application is fully ready for the job’s semantic operation.
If the main script succeeds but
after_script fails, what is the documented default
effect on the job exit result?
The after_script failure does not change the
successful main script exit code; preserve/log it separately if
it matters operationally.
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.