Steven's Knowledge

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=max

GitLab 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:
    - main

Same shape: lint → test → build. The vocabularies differ:

ConceptGitHub ActionsGitLab CI
The whole pipelineWorkflowPipeline
One unit of workJobJob
A step inside a jobStepA line in script:
Reusable building blockActionInclude / Template
Where the job runsRunner (runs-on)Runner (selected by tag)
Triggeron: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 Release

GitLab 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: manual

GitLab'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:

FlavorDescription
Hosted / cloudManaged by the platform; minutes billed
Self-hostedYou 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 logs

Configure 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 level

Configure 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=10m

GitLab 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=10m

Both 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=true as a secret; GitLab: re-run with CI_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

On this page