Pipeline as Code: GitHub Actions and GitLab CI
What This Concept Is
The pipeline definition lives in the same repository as the application code, in version-controlled YAML. It is reviewed, diffed, and evolved just like any other source. Both GitHub Actions and GitLab CI use this model; the syntax differs but the ideas are identical.
- GitHub Actions -- workflow files in
.github/workflows/*.yml. Built around workflows -> jobs -> steps. Steps call reusable actions (versioned). Runs on GitHub-hosted or self-hosted runners. - GitLab CI -- a single
.gitlab-ci.ymlat the repo root. Built around stages -> jobs -> scripts. Jobs run on GitLab-hosted or self-hosted runners. Reusable configuration comes frominclude:andextends:.
Pipeline-as-code replaces the UI-configured Jenkins era. You can no longer have a "works on Carol's laptop because Carol touched the Jenkins UI in 2019" pipeline. The same stance that Infrastructure-as-Code takes for cloud resources -- declarative, version-controlled, reviewable -- Pipeline-as-Code takes for the build and deploy path.
Why It Matters Here
Code-reviewed pipelines are how delivery itself becomes engineered:
- every pipeline change has the same diff-audit-review-approve cycle as application code
- rolling back a bad pipeline is
git revert - the pipeline history lives next to the code it built; no UI-only configuration to archive
- anyone on the team can read and reason about the delivery path, not just the "CI person"
- pipelines themselves become subject to the same hooks and policies as code (Pro Git's server-side hooks can enforce commit-message formats and ACL policies on pipeline-config repos too)
If pipelines are not in the repo, you do not have a single source of truth for how your software is shipped.
Concrete Example: GitHub Actions
A minimal but realistic Node.js workflow:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
build:
needs: test
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/acme/api
tags: |
type=sha,format=long
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
Concrete Example: GitLab CI
Equivalent logic in .gitlab-ci.yml:
stages: [test, build, deploy]
variables:
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
test:
stage: test
image: node:20
cache:
paths: [node_modules/]
script:
- npm ci
- npm run lint
- npm test -- --coverage
artifacts:
paths: [coverage/]
build:
stage: build
image: docker:26
services: [docker:26-dind]
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$IMAGE" .
- docker push "$IMAGE"
deploy_staging:
stage: deploy
environment: staging
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- ./deploy.sh --image "$IMAGE" --env staging
Both run. Both can be copy-pasted into a fresh repo.
Reuse: Composite Actions, Reusable Workflows, GitLab Includes
Once you have more than a few pipelines, duplication becomes the enemy. Both platforms offer three reuse tiers:
- GitHub composite actions -- a set of steps defined in a single action, versioned, callable from any workflow via
uses: org/repo/path@ref. - GitHub reusable workflows -- an entire workflow called from another workflow via
uses: org/repo/.github/workflows/foo.yml@ref. Good for "our standard build" factored once. - GitLab
include:andextends:-- include shared YAML from another file or project; extend a job with a base template.
A healthy pattern: organization-level "platform repo" holding standard build / test / release workflows; product repos consume them with minimal overrides. The delivery-path standardization this gives you is worth the initial setup cost within a few months.
Server-Side Policy: Pre-Push and Pre-Receive Hooks
Pipelines enforce what happens after a push. Pro Git's server-side hooks enforce what is allowed to push in the first place:
pre-receive-- can reject a push that does not match a commit-message format, contains a secret, or modifies a protected pathupdate-- per-ref check, useful to restrict who can push tomain- Signed commits / tags verification on the server so only trusted work reaches the pipeline
Modern forges expose these as branch protection rules in the UI (GitHub) or push rules (GitLab), which sit on top of the same git hook machinery Pro Git documents. The mental model is identical -- the UI is just a friendlier entry point to hooks/update and hooks/pre-receive.
Common Confusion / Misconception
"One big pipeline YAML is easier to maintain." Up to a point. Beyond ~300 lines, split: reusable workflows (Actions) or include: files (GitLab). The boundary is when one team's change repeatedly breaks another team's jobs.
"Pin to a version tag like @v4." You should, but know that @v4 is a moving tag. For security-critical actions, pin to a commit SHA (uses: actions/checkout@b4ffde65f46336...) so an attacker cannot re-point the tag and execute code in your runner. This is the same identity-by-digest discipline as concept 4.
"on: push is enough." Usually you want push on main and pull_request on all branches. Pushes to feature branches without a PR should not run the heavy pipeline. Use path filters (on.push.paths) in monorepos so unrelated services do not build on every commit.
"Debug pipelines by editing and pushing commits." This loop is painful. Use act (local Actions runner) or gitlab-runner exec to iterate locally. Or run the pipeline on a branch with workflow_dispatch: triggered manually. For shell-heavy scripts, bash -x (from the Linux Command Line shell-scripting chapters) is your friend; set -euo pipefail at the top of every non-trivial script catches the most common footguns.
"All secrets can live in repo secrets." They can be stored there, but the scoping matters: workflow-level, environment-level, or org-level. Concept 13 covers this in depth; for pipeline-as-code review, the important bit is that any secret touched by a pipeline must be owned, rotated, and minimally scoped.
How To Use It
Structure a pipeline for readability:
- One workflow per logical purpose (CI, release, nightly scans), not one workflow that does everything.
- Jobs run in parallel by default -- use
needs:/stage:only when a real dependency exists. - Secrets come from repo/environment settings, never hardcoded and never logged. Mark them as secrets in the UI or via protected variables.
- Cache heavy operations (node_modules, pip wheels, Docker layers) using the built-in cache actions.
- Pin action versions. Review Dependabot PRs for them.
- Factor shared logic into reusable workflows / includes once you have three repos doing the same thing.
Check Yourself
- Where does a workflow file live in GitHub Actions? In GitLab CI?
- What is the difference between a stage in GitLab and the
needs:keyword in Actions? - Why pin an action to a commit SHA instead of a tag?
- What YAML mistake would you look for if a job runs on every push but should only run on
main? - What do server-side git hooks enforce that pipeline YAML cannot?
Mini Drill or Application
Take a repo with no CI. Write a workflow that:
- runs on every PR and on pushes to
main - installs dependencies, runs the linter and unit tests
- only on
main: builds a container image and pushes it, tagged with the commit SHA - uses caching for dependencies
Target: < 60 lines of YAML, validates on first push.
Read This Only If Stuck
- Pro Git: Hooks -- overview
- Pro Git: Basic hooks usage and summary
- Pro Git: Other client hooks and server-side hooks
- Pro Git: Enforcing a commit-message format / user-based ACL
- The Linux Command Line: What are shell scripts
- The Linux Command Line: Shell functions and local variables
See also (external)
- GitHub Actions -- Writing workflows -- reference for syntax and semantics
- GitHub Actions -- Workflow syntax -- the canonical YAML reference
- GitHub Actions -- Reusable workflows -- sharing workflows across repos
- GitLab CI -- Get started -- reference for
.gitlab-ci.yml - GitLab CI --
.gitlab-ci.ymlkeyword reference -- every keyword explained - GitLab CI -- include keyword -- sharing configuration