Migrating GitHub Actions Workflows to GitLab CI With AI
Use AI to translate GitHub Actions YAML into idiomatic GitLab CI: map jobs and steps to stages, convert matrix builds, triggers, and secrets safely.
- #gitlab
- #ci-cd
- #ai
- #github-actions
- #migration
Last month I inherited a repository with eleven GitHub Actions workflows and a mandate to move the whole thing onto self-hosted GitLab. Nobody who wrote the original .github/workflows/*.yml files was still around. I had a wall of uses: lines, three matrix builds, OIDC into AWS, and a caching scheme I didn’t fully understand. Hand-porting all of it would have eaten a week.
So I did what I now do for every tedious-but-mechanical translation job: I treated an LLM like a fast junior engineer. It produced a first-draft .gitlab-ci.yml in minutes. Then I did the part that actually matters — reading every line, fixing the things it got subtly wrong, and refusing to feed it a single real secret. This guide walks through that workflow with real paired snippets, so you can see exactly what AI handles well and where you have to take the wheel.
Jobs and steps become stages and jobs
The first mental model shift: GitHub Actions has one workflow file with jobs, each containing a list of steps. GitLab CI has a flat list of jobs, each assigned to a stage, and ordering comes from the stages: array. There is no per-step abstraction — every job runs a script.
Here’s a GitHub Actions job:
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install
run: npm ci
- name: Build
run: npm run build
The AI-produced GitLab equivalent collapses the steps into a single script and drops the implicit checkout (GitLab clones the repo for you):
# .gitlab-ci.yml
stages:
- build
build:
stage: build
image: node:20
script:
- npm ci
- npm run build
Two things to verify here. First, runs-on: ubuntu-latest has no direct equivalent — you choose an image: and rely on runner tags: for placement, which the model often forgets. Second, the actions/checkout step simply disappears, which trips up reviewers who expect a one-to-one mapping. A code review pass catches both.
uses: actions turn into images and scripts
This is where AI saves the most time and also where it hallucinates the most. GitHub Actions are reusable marketplace units; GitLab has no marketplace, so each uses: has to become either a base image, a CLI invocation, or a GitLab CI/CD Component.
# GitHub Actions
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
tags: registry.example.com/app:${{ github.sha }}
# .gitlab-ci.yml
docker-build:
stage: build
image: docker:27
services:
- docker:27-dind
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
Notice the variable swap: ${{ github.sha }} becomes $CI_COMMIT_SHA, and the registry maps to GitLab’s built-in $CI_REGISTRY_IMAGE. The model usually nails the predefined-variable translation but invents flags for tools it half-remembers. Diff the generated docker build line against the action’s actual inputs before trusting it.
Pro Tip: Ask the AI to add a one-line comment above each translated block naming the original action it replaced (# was: docker/build-push-action@v6). It makes your review pass dramatically faster and leaves an audit trail for the next engineer.
Matrix builds map to parallel:matrix
GitHub’s strategy.matrix has a clean GitLab counterpart in parallel:matrix, and this is one of the cleanest translations the AI does.
# GitHub Actions
test:
strategy:
matrix:
node: [18, 20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
# .gitlab-ci.yml
test:
stage: test
image: node:${NODE_VERSION}
parallel:
matrix:
- NODE_VERSION: ["18", "20", "22"]
script:
- npm test
The key idiom: instead of a setup-node action, you parameterize the image: tag with the matrix variable. Watch the types — GitLab matrix values are strings, so 20 and "20" can behave differently when interpolated into an image tag. If you run wide matrices often, it’s worth saving this pattern in a reusable prompt library so you don’t re-derive it every migration.
Triggers: on: becomes rules and workflow
GitHub’s on: block is declarative and event-centric. GitLab uses rules: per job plus a top-level workflow: block to decide whether the pipeline runs at all. This is the section where AI most often produces plausible but wrong output, because the semantics genuinely differ.
# GitHub Actions
on:
push:
branches: [main]
pull_request:
branches: [main]
# .gitlab-ci.yml
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
build:
stage: build
script:
- npm run build
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
The conceptual trap: GitHub pull_request maps to GitLab’s merge_request_event, not to a branch condition. If the model writes $CI_COMMIT_BRANCH == "main" for the PR case, your merge request pipelines silently won’t run. Always test the generated rules against a throwaway MR before you delete the old workflows.
Secrets become CI/CD variables and OIDC — never paste them into the prompt
Say it with me: do not paste real secrets, tokens, or private keys into an AI prompt. Use obvious placeholders like <AWS_ROLE_ARN> and <DEPLOY_TOKEN>. The AI doesn’t need real values to translate structure, and anything you paste may be logged or retained.
# GitHub Actions
permissions:
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: <AWS_ROLE_ARN>
aws-region: us-east-1
- run: aws s3 sync ./dist s3://my-bucket
# .gitlab-ci.yml
deploy:
stage: deploy
image: amazon/aws-cli:2
id_tokens:
AWS_ID_TOKEN:
aud: https://gitlab.example.com
script:
- >
export $(aws sts assume-role-with-web-identity
--role-arn "$AWS_ROLE_ARN"
--role-session-name gitlab-ci
--web-identity-token "$AWS_ID_TOKEN"
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text | awk '{print "AWS_ACCESS_KEY_ID="$1; "..."}')
- aws s3 sync ./dist s3://my-bucket
GitHub’s id-token: write permission maps to GitLab’s id_tokens: block, and the configure-aws-credentials action becomes a manual assume-role-with-web-identity call. The AI will get the shape roughly right but frequently botches the credential-export plumbing — treat its OIDC output as a starting sketch, define AWS_ROLE_ARN as a masked project CI/CD variable, and verify the role trust policy yourself. Static secrets that were GitHub repo secrets become GitLab CI/CD variables (mark them masked and protected).
Pro Tip: After translation, grep the generated file for anything that looks like a literal credential. grep -nE 'AKIA|-----BEGIN|ghp_|glpat-' .gitlab-ci.yml should return nothing. If it returns a match, the AI either echoed something you shouldn’t have pasted or invented a sample value — both need to go.
needs: and dependencies
GitHub’s needs: creates a DAG; GitLab has both stage ordering and needs: for out-of-stage dependencies. The translation is direct, but artifacts are the catch.
# GitHub Actions
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
# .gitlab-ci.yml
deploy:
stage: deploy
needs:
- job: build
artifacts: true
script:
- ./deploy.sh
In GitHub, downloading a build artifact between jobs requires explicit upload-artifact/download-artifact actions. In GitLab, you declare artifacts: paths: on the producing job and they flow automatically to jobs that need: it. The AI tends to forget the producing side, so check that whatever deploy consumes is actually published upstream.
Caching
Both systems cache, but the keying philosophy differs. GitHub keys caches by hash; GitLab keys by key: (often a file hash) and ties cache to a paths: list.
# GitHub Actions
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
# .gitlab-ci.yml
build:
cache:
key:
files:
- package-lock.json
paths:
- .npm/
script:
- npm ci --cache .npm --prefer-offline
The subtle correctness issue: GitHub caches ~/.npm (a home-directory path), but GitLab caches are only reliable inside the project directory, so the AI should redirect the npm cache to .npm/ in the repo and pass --cache .npm. If it copies the home path verbatim, your cache will appear configured but never hit. This is exactly the kind of silent inefficiency a monitoring pass on your first few pipelines surfaces.
Conclusion
AI turned a week of tedious YAML translation into an afternoon of focused review, and that’s the right framing: it’s a fast junior engineer producing first drafts, not a migration tool you can merge unread. It excels at mechanical mapping — variables, stages, matrix syntax — and stumbles on semantics, triggers, OIDC plumbing, and cache paths, precisely the places where a wrong line fails silently. Keep two rules and you’ll be fine: review every generated job before it merges, and never paste a real secret into a prompt — placeholders only. If you do this kind of translation regularly, build up a stash of vetted patterns in the GitLab CI/CD guides and a prompt pack so each migration starts further down the road than the last.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.