What GitLab CI Users Miss When Switching to GitHub Actions
Real pain points from developers who've made the switch — and workarounds for the biggest gaps.
If you've used GitLab CI and recently switched to GitHub Actions, you've probably felt the friction. They're both CI/CD tools, but they're built on fundamentally different philosophies.
I recently asked the r/devops community about their biggest GitHub Actions frustrations, and the GitLab veterans had a lot to say. Here's what they miss most — and practical workarounds where they exist.
1. No Dynamic Pipeline Generation
The GitLab way: You can generate pipeline definitions at runtime. Fetch YAML from a URL, generate jobs based on what files changed, create child pipelines dynamically.
The GitHub way: Your workflow is static. What's in the YAML file is what runs.
Workaround: Use matrix strategies with fromJson() to dynamically generate jobs based on a JSON file or output from a previous step. It's not as elegant as GitLab's approach, but it works:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
# Generate matrix based on changed files
echo "matrix={...}" >> $GITHUB_OUTPUT
build:
needs: setup
strategy:
matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: echo "Building ${{ matrix.component }}"2. Self-Hosted Runner Concurrency
The GitLab way: Set concurrent = 4 in your runner config. One runner handles multiple jobs.
The GitHub way: One runner, one job. Want concurrency? Run multiple runners.
Workaround: Deploy multiple runner instances, or use a Kubernetes-based runner solution like Actions Runner Controller (ARC). It auto-scales runners as pods.
# ARC handles this at the infrastructure level
# Each job gets its own ephemeral podThis is more operationally complex than GitLab's approach, but scales better for large workloads.
3. Container Working Directory Leaves Root-Owned Files
The problem: When you run jobs in a container, GitHub mounts the workspace into it. Files created as root inside the container stay root-owned. The next job run fails because the runner user can't clean up the workspace.
Workaround: Add a cleanup step at the start of your jobs:
- name: Fix permissions
run: sudo chown -R $USER:$USER .Or use Docker's --user flag in a custom container action to run as the correct UID.
4. No Scoped Secrets Without Organizations
The GitLab way: Group-level variables cascade down to projects. Perfect for shared credentials.
The GitHub way: Org-level secrets or repo-level secrets. Nothing in between for teams or project groups.
Workaround: Use organization secrets with repository access policies. Not as granular as GitLab groups, but you can at least limit which repos can access which secrets.
For cross-repo private dependencies, use a GitHub App with create-github-app-token:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: "other-private-repo"
- uses: actions/checkout@v4
with:
repository: myorg/other-private-repo
token: ${{ steps.app-token.outputs.token }}This is more setup than GitLab's automatic CI_JOB_TOKEN, but it's more secure than long-lived PATs.
5. Opt-In Checkout (vs GitLab's Opt-Out)
The GitLab way: Your repo is automatically checked out. Disable it if you don't want it.
The GitHub way: Nothing happens unless you explicitly add actions/checkout.
The reality: This one's not really worse, just different. Explicit is arguably better — some jobs don't need the code at all. But if you're coming from GitLab, you'll forget the checkout step at least once.
steps:
- uses: actions/checkout@v4 # Don't forget this6. Manual Approvals Require Environments
The GitLab way: Add when: manual to any job.
The GitHub way: Use Environments with required reviewers. But environments are also used for deployments, which means approvers get notification spam.
Workaround: If you just need a "click to continue" gate without the deployment ceremony, consider using a workflow_dispatch with inputs instead:
on:
workflow_dispatch:
inputs:
confirmed:
description: 'Type YES to confirm'
required: trueNot as elegant as a button in the UI, but avoids the environment baggage.
7. Job Output Display Truncation
The problem: Long job outputs get cut off. GitLab lets you download the full log.
Workaround: Upload logs as artifacts:
- name: Run long command
run: ./build.sh 2>&1 | tee build.log
- uses: actions/upload-artifact@v4
if: always()
with:
name: build-log
path: build.log8. Monorepo Status Checks
The problem: Required status checks are repo-wide. In a monorepo, you can't require different checks for different paths.
This one hurts. There's no great workaround. Some teams use path filters to skip jobs entirely, then use if: always() on a final "gate" job that other checks can reference. It's hacky.
The best solution is often to split your monorepo into multiple repos, which... defeats the purpose.
The Philosophical Difference
One commenter put it perfectly:
"Calling Actions a CI or CD tool is a stretch. It's better to think of it as cloud functions which trigger on repository events."
That's not wrong. GitHub Actions is fundamentally an event-driven automation system that happens to be good at CI/CD. GitLab CI is a purpose-built CI/CD pipeline tool.
Neither is objectively better. But if you're coming from GitLab expecting the same mental model, you're going to fight the tool instead of using it.
What Runless Does
Runless analyzes your GitHub Actions workflows and catches common issues — missing timeouts, unpinned actions, inefficient caching patterns. It won't fix the architectural differences between platforms, but it will help you avoid the footguns that make GitHub Actions slower and more expensive than it needs to be.
