GitLab CI skill for Claude Code

One pipeline per change, jobs that start early. Claude adds workflow rules so branch and merge request pipelines stop doubling up, converts stage chains into a needs DAG, and separates lockfile-keyed caches from artifacts that later jobs rely on. Shared config moves into extends, includes and pinned CI/CD components, and deploys get protected environments, a resource group, a manual gate and OIDC tokens.

  • namegitlab-ci
  • size244 lines · ≈1,950 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes and optimizes .gitlab-ci.yml pipelines — workflow rules that prevent duplicate pipelines, job rules with changes and manual gates, needs-based DAGs, cache vs. artifacts, test reports, includes and CI/CD components, environments with resource groups, and OIDC id_tokens for cloud deploys. Use when creating or debugging GitLab CI pipelines, when pipelines run twice or jobs never start, when CI is slow, or when setting up deploy stages and protected environments.

Try asking

  • Every push to my MR branch starts two pipelines. Fix our .gitlab-ci.yml
  • Our GitLab pipeline takes 20 minutes; restructure it so jobs run in parallel
  • Add a manual production deploy job that uses OIDC instead of AWS keys

Use it when

  • You are writing or refactoring .gitlab-ci.yml
  • Pipelines run twice, or jobs are skipped when they should run
  • CI is slow because jobs wait on whole stages
  • You are setting up environments and deploy gates in GitLab

Skip it when

How to check the result

glab ci lint passes, a branch with an open merge request produces exactly one pipeline, and the needs graph shows jobs starting as soon as their dependencies finish.

The common mistake it prevents

Leaving out workflow rules, so every push to a branch with an open merge request triggers both a branch pipeline and an MR pipeline.

~/.claude/skills/gitlab-ci/SKILL.md

--- name: gitlab-ci description: Writes and optimizes .gitlab-ci.yml pipelines — workflow rules that prevent duplicate pipelines, job rules with changes and manual gates,… license: MIT ---

A good pipeline runs once per change (not once for the branch and again for the MR), starts each job as soon as its real dependencies finish, caches dependencies but passes build outputs as artifacts, and deploys through protected environments that serialize with a resource group.

Ground rules

  • Use rules:. only/except are legacy and do not combine well with rules.
  • Define workflow:rules at the top so branch and MR pipelines do not both run.
  • Cache is a best-effort speedup; artifacts are the contract between jobs.
  • Deploy jobs: environment:, resource_group:, protected variables, and a manual gate for production. Triggering a production deploy requires user confirmation.
  • Validate the YAML before pushing (glab ci lint or the pipeline editor).

Workflow

- [ ] 1. Read the current file, includes, and recent pipeline durations
- [ ] 2. Add workflow:rules and defaults
- [ ] 3. Define stages, then needs for a DAG
- [ ] 4. Cache dependencies; artifacts for outputs and reports
- [ ] 5. Factor shared config (extends, !reference, include, components)
- [ ] 6. Deploy jobs with environments, resource_group, OIDC
- [ ] 7. Lint, run on a branch, compare durations

1. Inspect

glab ci lint                      # validates .gitlab-ci.yml against the project
glab ci view                      # interactive view of the current pipeline
glab ci list                      # recent pipelines and status

Use the pipeline editor’s “Full configuration” tab (or the CI Lint API) to see the merged result of all include: files before debugging rule logic.

2–4. Baseline pipeline

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG

default:
  image: node:22-bookworm-slim
  interruptible: true
  retry:
    max: 2
    when: [runner_system_failure, stuck_or_timeout_failure]

stages: [build, test, deploy]

.node_cache: &node_cache
  key:
    files: [package-lock.json]
  paths: [.npm/]
  policy: pull

install:
  stage: build
  script:
    - npm ci --cache .npm --prefer-offline
    - npm run build
  cache:
    <<: *node_cache
    policy: pull-push
  artifacts:
    paths: [dist/, node_modules/]
    expire_in: 1 day

unit:
  stage: test
  needs: [install]
  script:
    - npm test -- --reporter=junit --outputFile=junit.xml --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    when: always
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

lint:
  stage: test
  needs: [install]
  script: [npm run lint]

e2e:
  stage: test
  needs: [install]
  parallel: 4
  script:
    - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        paths: [src/**/*, e2e/**/*]
        compare_to: refs/heads/main
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Passing large node_modules as an artifact is a tradeoff; for big trees, cache it per lockfile and re-run npm ci instead.

Cache vs. artifacts

DataMechanismWhy
Package manager download cachecache: keyed on lockfileReusable across pipelines, safe to miss
Build output used by later jobsartifacts: + needs:Guaranteed to exist for that pipeline
Test results / coverageartifacts:reportsShows in MR widget
Files for humans (logs, screenshots)artifacts: with when: on_failure and expire_inDebugging

Use policy: pull on jobs that only read the cache; only one job should push it. Caches are per runner unless a distributed cache (S3/GCS) is configured.

Rules cheat sheet

GoalRule
Only in MRsif: $CI_PIPELINE_SOURCE == "merge_request_event"
Only on default branchif: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Only for tagsif: $CI_COMMIT_TAG
When files changedchanges: { paths: [...], compare_to: refs/heads/main }
Manual, non-blockingwhen: manual + allow_failure: true
Manual, blocks later stageswhen: manual (allow_failure defaults to false in rules)
Scheduled jobs onlyif: $CI_PIPELINE_SOURCE == "schedule"
Skip everything elsefinal - when: never (implicit when no rule matches)

Rules are evaluated in order; the first match wins.

5. Reuse

include:
  - local: ci/templates.yml
  - project: platform/ci-templates
    ref: v3.2.0
    file: /node.yml
  - component: $CI_SERVER_FQDN/platform/components/[email protected]
    inputs:
      image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

.deploy_base:
  image: alpine:3.22
  before_script: [apk add --no-cache aws-cli]

deploy-staging:
  extends: .deploy_base
  script:
    - !reference [.deploy_base, before_script]
    - ./deploy.sh staging

Pin include:project and components to a tag or SHA, never a moving branch.

6. Deploy with environments and OIDC

deploy-production:
  stage: deploy
  needs: [unit, lint]
  image: alpine:3.22
  environment:
    name: production
    url: https://shop.example.com
  resource_group: production
  id_tokens:
    AWS_ID_TOKEN:
      aud: https://gitlab.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  script:
    - apk add --no-cache aws-cli
    - >
      export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
      $(aws sts assume-role-with-web-identity
      --role-arn "$AWS_ROLE_ARN"
      --role-session-name "gitlab-$CI_PIPELINE_ID"
      --web-identity-token "$AWS_ID_TOKEN"
      --duration-seconds 3600
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))
    - ./deploy.sh production
  • Restrict the IAM trust policy on the token’s sub claim (project_path:group/project:ref_type:branch:ref:main).
  • Mark the production environment as protected so only maintainers can run it.
  • resource_group ensures two deploys never run concurrently.

Container builds

Prefer BuildKit rootless or Buildah on shared runners. Docker-in-Docker needs a privileged runner (services: [docker:dind]) and DOCKER_TLS_CERTDIR: "/certs". Kaniko’s upstream Google repository was archived in 2025; use a maintained fork if the project depends on it.

7. Verify

  • glab ci lint passes; the merged configuration shows the expected jobs.
  • Push a branch with an open MR: exactly one pipeline appears (the MR pipeline).
  • Check the needs graph view: jobs start as soon as dependencies finish.
  • Compare pipeline duration across three warm runs, not one.

Gotchas

  • Without workflow:rules, pushing to a branch with an open MR creates two pipelines.
  • changes: without compare_to on branch pipelines compares to the previous push; on a new branch it evaluates to true for every file.
  • Jobs without needs download artifacts from all earlier stages; use needs or dependencies: [] to avoid pulling GBs.
  • Protected variables are only exposed on protected branches and tags; a deploy that works on main fails on a feature branch by design.
  • Masked variables must meet length and character rules or masking is refused.
  • interruptible: true plus “auto-cancel redundant pipelines” cancels old runs; never mark deploy jobs interruptible.
  • YAML anchors do not work across include: files; use extends or !reference.

Output

**Pipeline changes:** workflow:rules added (no more duplicate MR/branch pipelines),
DAG via needs, lockfile-keyed npm cache, e2e sharded x4, prod deploy behind manual
gate + resource_group, OIDC replaces AWS keys
**Lint:** glab ci lint OK
**Duration (MR, warm):** 18m -> 7m30s

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc