CI/CD Translation Reference
Mapping GitLab CI concepts and patterns to GitHub Actions equivalents. Use alongside gh actions-importer for automated translation, then review and adjust using this reference.
Pipeline Structure​
| GitLab CI | GitHub Actions | Notes |
|---|---|---|
.gitlab-ci.yml | .github/workflows/*.yml | GitHub supports multiple workflow files |
stages: | Jobs with needs: dependencies | No explicit stage ordering; use needs: for DAG |
extends: | Reusable workflows / composite actions | uses: ./.github/workflows/reusable.yml |
include: | Reusable workflows | uses: org/repo/.github/workflows/shared.yml@main |
needs: | needs: | Same concept, same keyword |
resource_group: | concurrency: groups | concurrency: { group: ..., cancel-in-progress: false } |
environment: | environment: | Same concept; GitHub adds protection rules |
Docker & Services​
| GitLab CI | GitHub Actions | Notes |
|---|---|---|
image: | runs-on: + container: | runs-on selects the runner; container sets the Docker image |
services: | services: in container jobs | Requires container: on the job; services don't work on bare runners |
macOS runners (saas-macos-medium-m1) | runs-on: macos-14 | M1-based, available on Enterprise |
pages job | actions/deploy-pages or Cloudflare Pages | See docs hosting section |
Artifacts & Cache​
| GitLab CI | GitHub Actions | Notes |
|---|---|---|
artifacts: | actions/upload-artifact / actions/download-artifact | GitLab passes artifacts between stages automatically; GitHub requires explicit steps |
cache: | actions/cache | Or use built-in caching in setup actions like actions/setup-node with cache: npm |
Variables & Secrets​
| GitLab CI | GitHub Actions | Notes |
|---|---|---|
| CI/CD Variables | Repository/org secrets + variables | Secrets are encrypted; variables are plaintext |
CI_JOB_TOKEN | GITHUB_TOKEN | Auto-generated per workflow run |
CI_REGISTRY / CI_REGISTRY_IMAGE | ghcr.io | GitHub Container Registry |
CI_COMMIT_SHA | github.sha | Context expressions |
CI_COMMIT_REF_NAME | github.ref_name | Context expressions |
CI_PIPELINE_SOURCE | github.event_name | push, pull_request, workflow_dispatch, etc. |
| Protected variables | Environment secrets | Secrets scoped to specific environments |
Triggers & Conditions​
| GitLab CI | GitHub Actions | Notes |
|---|---|---|
rules: / only: / except: | on: triggers + job if: conditions | GitHub triggers are more explicit |
when: manual | Split-gate: approval job + deploy job | A job with environment: + reviewers gates the approval; a dependent job owns the concurrency: group and runs the deploy. See Deploy Approval Pattern. workflow_dispatch is fine for one-off manual workflows but does not integrate with PR checks. |
Laravel Vapor Deploys​
The GitLab setup shipped a separate vapor-base.Dockerfile, pushed it to the GitLab container registry, and referenced it from staging.Dockerfile / production.Dockerfile. That whole pipeline is unnecessary on GitHub: vapor deploy builds the Docker image internally, so the env Dockerfiles can inherit from laravelphp/vapor:phpXX directly and fold in any runtime extras (ghostscript, fontconfig, etc.). Validated on coniglio (commit 9ec2f8b, before coniglio moved to Cloud) and farfalla-integrations. Applies to farfalla, castoro.
GitLab exposes $CI_COMMIT_MESSAGE automatically; GitHub does not. For vapor deploy --message, set GITHUB_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} (or github.event.pull_request.title for PR-triggered staging) in the deploy step's env: block.
Laravel Cloud Deploys​
Laravel Cloud deploys fire from a webhook URL. The GitHub Actions job only POSTs to that URL; the actual build runs server-side on Laravel Cloud.
curl -X POST <hook> returns immediately; the deploy happens in the background on Laravel Cloud. Don't script post-deploy health checks expecting the deploy to be done when curl returns. And don't reach for a beefier runner to speed up the deploy step — the GitHub runner only fires a curl, the image build happens on Cloud. Validated on coniglio, medusa, farfalla-https-guard.
Bare curl -X POST returns 0 on 4xx/5xx, so a rejected hook silently marks the deploy green. Use the same form as all migrated repos:
curl --fail-with-body -sS \
--retry 3 --retry-all-errors \
--connect-timeout 10 --max-time 30 \
-X POST "${{ secrets.LARAVEL_CLOUD_*_DEPLOY_HOOK }}?commit_hash=${{ github.sha }}"
--fail-with-body is the load-bearing flag; the retries/timeouts guard against transient network blips.
github.sha works for push events (merges to dev/main), but on pull_request events it's the ephemeral merge commit GitHub creates against the base branch — a SHA that doesn't exist on the PR's branch and won't resolve on Cloud's dashboard. For PR-triggered deploy jobs (e.g. manual-deploy-staging), use github.event.pull_request.head.sha instead. See Deploy approval pattern for the full job split.
.cloud/config.jsonWhen the Laravel Cloud project's source integration is switched from GitLab to GitHub, Cloud generates a .cloud/config.json file in the repo with the project's organization and application IDs. Commit it — Cloud reads it to identify the project on subsequent deploys. Non-secret. See coniglio and farfalla-https-guard for examples.
Custom CI Images on GHCR​
Repos that need a non-trivial CI image (system packages, baked dependency installs, custom-built tooling) build it via a sibling workflow and publish to GitHub Container Registry. Two patterns matter for the consuming workflow.
Build workflow validates on PRs without pushing​
The image-build workflow should run on PRs touching the Dockerfile or any baked-in inputs (e.g. package.json, yarn.lock), but only push on merge. A broken image fails the PR check before it can poison :latest on main:
on:
push:
branches: [main]
paths: [ci.Dockerfile, package.json, yarn.lock]
pull_request:
paths: [ci.Dockerfile, package.json, yarn.lock]
workflow_dispatch:
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@<sha> # v3.x, pin to commit SHA
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@<sha> # v6.x, pin to commit SHA
with:
context: .
file: ci.Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/publicala/<repo>-ci:latest
The push: expression is the load-bearing piece — true on push/workflow_dispatch, false on pull_request. Validated on castoro.
Consuming a private GHCR image from another workflow​
GHCR packages are private by default and pulls always require token auth — there is no "anonymous internal" mode. Three steps to wire a sibling repo (or the same repo's ci.yml) to pull the image transparently via GITHUB_TOKEN:
- Link the package to the consuming repo. At
https://github.com/orgs/publicala/packages/container/<image>/settings, under Manage Actions access, grant the consuming repoRead. One-time per consuming repo. - Add
packages: readto the consuming workflow'spermissions:block. This scopesGITHUB_TOKENfor the pull:permissions:
contents: read
packages: read - Reference the image in
container:. With the grant + permissions in place, nocontainer.credentialsblock is needed:jobs:
test:
runs-on: depot-ubuntu-24.04
container: ghcr.io/publicala/<repo>-ci:latest
Set the package visibility to Internal if multiple org repos pull the image — same auth model, but org members can browse and pull without an explicit per-repo grant. Keep it private + per-repo grant when only one repo consumes it.
Gotchas​
Sentry release tracking and deploy notifications need to be reconfigured for the GitHub integration. Projects moving to Laravel Cloud will transition from Sentry to Nightwatch. Affected repos: farfalla, medusa, farfalla-integrations, coniglio.
GitLab has built-in cross-project pipeline triggers. GitHub Actions uses repository_dispatch events with a PAT or GitHub App token to trigger workflows in other repos. Affected repos: micelio (used to trigger criceto's reader-web group; deferred until criceto migrates), volpe/farfalla (Delfino version validation).
The GitLab-era send_slack_notification.sh shell script read GitLab-only env vars (CI_PROJECT_NAME, CI_JOB_NAME, CI_PIPELINE_URL, CI_COMMIT_*) and renders mostly empty on GitHub. Cloudflare Workers repos use the shared composite action publicala/slack-deploy-notify@v1 instead, which reads the GitHub Actions context internally so consumer workflows stay one step:
- name: Notify Slack
if: always()
uses: publicala/slack-deploy-notify@v1
with:
webhook-url: ${{ secrets.SLACKWEBHOOK_ENG_DEPLOYS }}
status: ${{ job.status }}
environment: staging
Production deploys use secrets.SLACKWEBHOOK_ENG_PRODUCTION_DEPLOYS instead. The app input defaults to the repo name; only set it when the deploy target name differs from the repo (e.g. one repo that ships to multiple workers). if: always() is required for failure pings; drop it if success-only notifications are enough. Affected repos: micelio, vito, repos-to-slack-notifications-proxy (the proxy is still on the inline-step pattern; planned follow-up migrates it to the shared action).
Playwright on Depot​
Two non-obvious gotchas for any repo running Playwright Test on Depot runners. Both surfaced during the vito migration.
Playwright Test 1.52 hangs indefinitely on Node 24 during config and spec discovery. The main thread blocks in V8's Atomics.wait on a SharedArrayBuffer that never gets signaled (sample stack tip: __psynch_cvwait). No reporter output, no error. Reproduces locally with npx playwright test --list. The same suite runs cleanly on Node 23.
Fixed in Playwright 1.60. If you can upgrade @playwright/test, do that and stay on Node 24. If you can't, pin Node 23 via node-version: 23 in actions/setup-node and a matching .nvmrc in the repo.
container: mcr.microsoft.com/playwright hangs silentlySetting container: mcr.microsoft.com/playwright:vX.Y.Z-noble on a Depot job makes npx playwright test produce zero stdout for the full job timeout. The hang is in container-mode plumbing, not Playwright itself (the same Playwright runs to completion on a bare Depot runner). Adding --ipc=host --user root does not help. None of the public Depot+Playwright workflows surveyed use container: for Playwright (dify, biomejs, PostHog, evcc-io, CopilotKit). Use a bare depot-ubuntu-* runner and install browsers via npx playwright install --with-deps chromium.
Cache ~/.cache/ms-playwright with actions/cache@v5, keyed by the Playwright version read from package-lock.json (or via node -p "require('@playwright/test/package.json').version"). On cache hit, run playwright install-deps <browser> (system libs only, fast). On miss, run playwright install --with-deps <browser>.
Playwright defaults to one worker per CPU on CI. The 2-vCPU baseline (depot-ubuntu-24.04) caps parallelism at one worker, which makes e2e the slowest job and dominates total pipeline wall-clock (deploy waits on it via needs:). Bump e2e to depot-ubuntu-24.04-4 (or larger) to recover parallelism. Validated on vito: e2e dropped from 144s to 91s on 4 vCPU.