Getting Started
First workflow on GitHub Actions and GitLab CI, runners, triggers, and the basic build-test-deploy loop
Getting Started
Both GitHub Actions and GitLab CI run pipelines defined in your repo. This page shows the minimum useful workflow on each, side by side, so the vocabulary clicks before you dive into platform-specific features.
The Minimum CI Workflow
Lint, test, build — three stages, run on every PR.
GitHub Actions
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
lint:
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
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 test
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxGitLab CI
# .gitlab-ci.yml
stages: [lint, test, build]
variables:
NODE_VERSION: "20"
default:
image: node:${NODE_VERSION}-alpine
cache:
paths: [node_modules/]
lint:
stage: lint
script:
- npm ci
- npm run lint
test:
stage: test
script:
- npm ci
- npm test
build:
stage: build
image: docker:24
services: [docker:24-dind]
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
only:
- mainSame shape: lint → test → build. The vocabularies differ:
| Concept | GitHub Actions | GitLab CI |
|---|---|---|
| The whole pipeline | Workflow | Pipeline |
| One unit of work | Job | Job |
| A step inside a job | Step | A line in script: |
| Reusable building block | Action | Include / Template |
| Where the job runs | Runner (runs-on) | Runner (selected by tag) |
| Trigger | on: | rules: / only: / except: |
Trigger Patterns
Both platforms run on push events; both can also run on schedules, manual buttons, releases, and external webhooks.
GitHub Actions
on:
pull_request: # any PR
push:
branches: [main, 'release/*'] # main and release branches
schedule:
- cron: "0 6 * * *" # daily at 06:00 UTC
workflow_dispatch: # manual button in UI
inputs:
environment: { type: choice, options: [staging, production] }
release:
types: [published] # on GitHub ReleaseGitLab CI
job-name:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_TAG' # any tag
- if: '$CI_PIPELINE_SOURCE == "schedule"' # scheduled
- if: '$CI_PIPELINE_SOURCE == "web"' # manual via UI
when: manualGitLab's rules: is the modern way; older only: / except: still works.
Runners
A runner is the machine (or container) that executes your jobs. Two flavors on both platforms:
| Flavor | Description |
|---|---|
| Hosted / cloud | Managed by the platform; minutes billed |
| Self-hosted | You run the agent on your own VMs / K8s; free compute, you own ops |
You'll start with hosted runners (ubuntu-latest on Actions, the default shared runners on GitLab). Self-hosted comes when:
- You need bigger machines (GPU, RAM, disk).
- You need to access private infrastructure.
- You're hitting cost ceilings with hosted minutes.
- You need specific OS/arch (e.g. Apple Silicon for iOS builds).
# GitHub Actions — self-hosted
runs-on: [self-hosted, linux, x64, gpu]
# GitLab CI — tag-based
job:
tags: [docker, large]Variables and Secrets
Both platforms split between plain variables (visible in logs by default) and secrets (masked).
GitHub Actions
env:
NODE_ENV: production # workflow-wide
jobs:
deploy:
env:
REGION: us-east-1 # job-wide
steps:
- run: |
echo "$NODE_ENV"
echo "Token starts with: ${SECRET:0:4}..."
env:
SECRET: ${{ secrets.MY_SECRET }} # masked in logsConfigure secrets in Settings → Secrets and variables → Actions. Scopes: repo, environment, organization.
GitLab CI
variables:
NODE_ENV: production
deploy:
script:
- echo "$NODE_ENV"
- echo "Token starts with: ${SECRET:0:4}..."
variables:
SECRET: $MY_SECRET # configured at project / group levelConfigure in Settings → CI/CD → Variables. Mark as Masked (hide in logs) and Protected (only on protected branches/tags).
The default is "any branch can read all variables." Mark sensitive variables Protected so only protected branches (main, release/*) can read them. Otherwise a PR from a fork can exfiltrate secrets via echo.
A Realistic Deploy Job
Both examples push to a registry and then deploy via kubectl:
GitHub Actions
deploy-staging:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: staging # requires reviewer approval if you configured it
permissions:
id-token: write # for AWS OIDC
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- uses: azure/setup-kubectl@v4
- run: |
aws eks update-kubeconfig --name staging-cluster
kubectl set image deployment/api \
api=ghcr.io/${{ github.repository }}:${{ github.sha }}
kubectl rollout status deployment/api --timeout=10mGitLab CI
deploy-staging:
stage: deploy
needs: [build]
image: alpine/k8s:1.30.0
environment:
name: staging
url: https://staging.example.com
id_tokens:
AWS_TOKEN:
aud: sts.amazonaws.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- aws sts assume-role-with-web-identity \
--role-arn "$DEPLOY_ROLE_ARN" \
--web-identity-token "$AWS_TOKEN" \
--role-session-name gitlab-ci
- aws eks update-kubeconfig --name staging-cluster
- kubectl set image deployment/api api="$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
- kubectl rollout status deployment/api --timeout=10mBoth use OIDC for short-lived AWS credentials — no long-lived AWS_ACCESS_KEY_ID sitting in CI variables. That's the modern pattern; details on each in the platform pages.
Reading Logs
Both UIs show a tree: pipeline → job → step. Useful habits:
- Click into the failing step first — the error is usually at the bottom.
- Re-run the job with debug logging enabled if the output isn't enough (Actions:
ACTIONS_RUNNER_DEBUG=trueas a secret; GitLab: re-run withCI_DEBUG_TRACE=true). - Download artifacts — coverage reports, screenshots, builds — instead of paging through logs.
What's Next
You can run a basic pipeline on either platform. Next, learn each one's strengths in depth:
- GitHub Actions — matrix builds, OIDC patterns, reusable workflows, self-hosted runners
- GitLab CI — stages vs
needs:(DAG), parent/child pipelines, runners - Best Practices — speed, caching, security, pipeline design