BLOG/TEAM CI/CD
THEME
TEAM CI/CD

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.

Jun 3, 2026 OrchStep Team 5 minROLE: DevOps EngineerSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/gitlab-ci-stages
VIEW SOURCE

Read 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.

.gitlab-ci.yml
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:

orchstep.yml
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=production

Same steps, same variable resolution, same exit code — in your terminal, in two seconds.

What you actually gained

ConcernInline script: blocksOne task per stage
Read the pipelineparse shell across N jobsa table of contents
Run a stage locallycopy the script: by handorchstep run <task>
Pass data between stagesartifacts: plumbingstep outputs within a task
Per-target configvariables: + rules:--var + defaults
Move off GitLabrewrite every jobreuse 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

Got a .gitlab-ci.yml nobody can read? Give each stage one task to call — curl -fsSL https://orchstep.dev/install.sh | sh.

#GITLAB-CI#CI#PIPELINES#DEVOPS
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — TEAM CI/CD