The pipeline kept running all three jobs in parallel. Not what I wanted. I had a build job producing a compiled binary, a test job that needed that binary, and a deploy job. Without stages: defined explicitly, GitLab defaults to running everything it can concurrently, and artifacts from a job in the same implicit stage don’t propagate the way you’d expect.
The fix is obvious in hindsight but the docs bury it.
You need stages: at the top of .gitlab-ci.yml, and each job must declare which stage it belongs to. That’s what controls order. Artifacts then flow forward automatically to jobs in later stages, but only if you declare artifacts: paths: on the producing job. The consuming job doesn’t need to do anything special, it just finds the files at the same relative path they were saved from.
One thing that actually bit me: the artifact wasn’t showing up in the deploy job. I had expire_in: 1 hour set during testing and forgot the pipeline was queued for 90 minutes before the runner picked it up. Artifact expired before deploy ran. Changed it to expire_in: 1 day and stopped thinking about it.
.gitlab-ci.yml
stages:
- build
- test
- deploy
build_app:
stage: build
image: golang:1.22
script:
- go build -o bin/app ./cmd/app
artifacts:
paths:
- bin/app
expire_in: 1 day
run_tests:
stage: test
image: golang:1.22
script:
- ./bin/app --version
- go test ./...
dependencies:
- build_app
deploy_to_staging:
stage: deploy
image: alpine:3.19
script:
- apk add --no-cache openssh-client
- scp -i $SSH_KEY bin/app deploy@$STAGING_HOST:/opt/app/
- ssh -i $SSH_KEY deploy@$STAGING_HOST "systemctl restart app"
environment:
name: staging
url: https://staging.example.com
only:
- main
dependencies vs needs
The dependencies: key on run_tests tells GitLab which jobs’ artifacts to download. If you omit it, GitLab downloads artifacts from all previous stage jobs, which is usually fine but gets messy in larger pipelines with many parallel jobs producing different artifacts. Being explicit is cleaner.
needs: is a different thing. It lets a job start before its stage officially begins, creating a DAG instead of a linear stage sequence. Useful when you have independent jobs and want to cut total pipeline time. But if you use needs:, GitLab won’t automatically pass artifacts unless you also set artifacts: true inside the needs: block.
# needs: with artifact passing
deploy_to_staging:
stage: deploy
needs:
- job: build_app
artifacts: true
Without artifacts: true there, the deploy job starts early but finds an empty bin/ directory. GitLab doesn’t warn you about it.
environment variables for SSH
The $SSH_KEY and $STAGING_HOST variables go in Settings, CI/CD, Variables. Mark the key as type File, not Variable, so GitLab writes it to a temp path and you can pass that path directly to scp and ssh -i. If you set it as Variable, you get the raw key contents injected as an env var and then have to write it to a file yourself in the script, which is just extra steps and a place to get permissions wrong (chmod 600 or ssh rejects it).
Also set the variable to protected and masked if the branch is protected. Otherwise any pipeline on any branch can read it.
