GitLab CI pipelines that read like intent
A .gitlab-ci.yml should read like a table of contents, not a shell script smeared across stages. Let each stage call one orchstep task and keep the work where you can run it.
blog/gitlab-ci-stagesRead a mature .gitlab-ci.yml out loud and you lose the plot by line 40. Each job has a script: block of five to fifteen shell lines, a before_script: that sets up half of them, rules: that decide when it runs, and artifacts: plumbing between stages. The intent — build, then test, then deploy — is buried under the mechanics of how each one happens.
GitLab's stage model is actually a clean way to express intent: ordered stages, each a phase of the pipeline. The problem is that the work got inlined into the jobs instead of named somewhere you can read and run.
Pull the work out. Let each stage say what phase it is and call one task.
The pipeline as intent
Here's the whole thing GitLab needs to know: three stages, each one task call. It reads top to bottom like a description of the pipeline, because that's all it is.
stages: [build, test, deploy]
default:
before_script:
- curl -fsSL https://orchstep.dev/install.sh | sh
build:
stage: build
script: orchstep run build
test:
stage: test
script: orchstep run test
deploy:
stage: deploy
script: orchstep run deploy --var target=production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'The rules: stay in GitLab — deciding when a stage runs against branch and pipeline context is exactly what GitLab is for. What left is the how: every script: is now a single line.
The work, named and runnable
The tasks those stages call live in one file you can run on your laptop, exactly as the runner will:
name: service
# Each GitLab stage is one `orchstep run <task>`. The .gitlab-ci.yml reads
# like a table of contents; the actual work is named and ordered here.
defaults:
target: staging
tasks:
# stage: build -> `orchstep run build`
build:
steps:
- name: compile
func: shell
do: echo "compiling service"
outputs:
artifact: "dist/service.tar.gz"
- name: report
func: shell
do: echo "built {{ steps.compile.artifact }}"
# stage: test -> `orchstep run test`
test:
steps:
- name: unit
func: shell
do: echo "running unit tests"
- name: integration
func: shell
do: echo "running integration tests"
# stage: deploy -> `orchstep run deploy --var target=production`
deploy:
steps:
- name: ship
func: shell
do: echo "deploying service to {{ vars.target }}"
- name: verify
func: shell
do: echo "verifying {{ vars.target }} is healthy"The mapping is one-to-one: a comment over each task tells you which stage calls it. When a reviewer asks "what does the deploy stage actually do," the answer is four readable lines, not a script: block plus a before_script: plus two variables: you have to cross-reference.
Debug a stage without a pipeline
The deploy stage fails on the runner. Instead of editing .gitlab-ci.yml and pushing to trigger a pipeline, you run the same task:
orchstep run deploy --var target=productionSame steps, same variable resolution, same exit code — in your terminal, in two seconds.
What you actually gained
| Concern | Inline script: blocks | One task per stage |
|---|---|---|
| Read the pipeline | parse shell across N jobs | a table of contents |
| Run a stage locally | copy the script: by hand | orchstep run <task> |
| Pass data between stages | artifacts: plumbing | step outputs within a task |
| Per-target config | variables: + rules: | --var + defaults |
| Move off GitLab | rewrite every job | reuse the same tasks |
GitLab still owns stages, rules:, artifacts between jobs, and runner selection — none of that moves. What moves is the shell that piled up inside the jobs because that was the only place to put it.
Where to go next
- Quick Start — your first workflow in two minutes
- Variables & Outputs —
outputsand--varacross stages - Previewing with Dry Run — resolve a stage's plan before pushing
Got a .gitlab-ci.yml nobody can read? Give each stage one task to call — curl -fsSL https://orchstep.dev/install.sh | sh.
curl -fsSL https://orchstep.dev/install.sh | sh