Skip to main content

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 CIGitHub ActionsNotes
.gitlab-ci.yml.github/workflows/*.ymlGitHub supports multiple workflow files
stages:Jobs with needs: dependenciesNo explicit stage ordering; use needs: for DAG
extends:Reusable workflows / composite actionsuses: ./.github/workflows/reusable.yml
include:Reusable workflowsuses: org/repo/.github/workflows/shared.yml@main
needs:needs:Same concept, same keyword
resource_group:concurrency: groupsconcurrency: { group: ..., cancel-in-progress: false }
environment:environment:Same concept; GitHub adds protection rules

Docker & Services​

GitLab CIGitHub ActionsNotes
image:runs-on: + container:runs-on selects the runner; container sets the Docker image
services:services: in container jobsRequires container: on the job; services don't work on bare runners
macOS runners (saas-macos-medium-m1)runs-on: macos-14M1-based, available on Enterprise
pages jobactions/deploy-pages or Cloudflare PagesSee docs hosting section

Artifacts & Cache​

GitLab CIGitHub ActionsNotes
artifacts:actions/upload-artifact / actions/download-artifactGitLab passes artifacts between stages automatically; GitHub requires explicit steps
cache:actions/cacheOr use built-in caching in setup actions like actions/setup-node with cache: npm

Variables & Secrets​

GitLab CIGitHub ActionsNotes
CI/CD VariablesRepository/org secrets + variablesSecrets are encrypted; variables are plaintext
CI_JOB_TOKENGITHUB_TOKENAuto-generated per workflow run
CI_REGISTRY / CI_REGISTRY_IMAGEghcr.ioGitHub Container Registry
CI_COMMIT_SHAgithub.shaContext expressions
CI_COMMIT_REF_NAMEgithub.ref_nameContext expressions
CI_PIPELINE_SOURCEgithub.event_namepush, pull_request, workflow_dispatch, etc.
Protected variablesEnvironment secretsSecrets scoped to specific environments

Triggers & Conditions​

GitLab CIGitHub ActionsNotes
rules: / only: / except:on: triggers + job if: conditionsGitHub triggers are more explicit
when: manualSplit-gate: approval job + deploy jobA 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​

Vapor does not need DinD or a pre-built base image

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.

Commit message env var

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.

Hooks are async, image builds run server-side

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.

Use the hardened curl form

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.

Pick the right SHA for the event

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.

Commit .cloud/config.json

When 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:

  1. Link the package to the consuming repo. At https://github.com/orgs/publicala/packages/container/<image>/settings, under Manage Actions access, grant the consuming repo Read. One-time per consuming repo.
  2. Add packages: read to the consuming workflow's permissions: block. This scopes GITHUB_TOKEN for the pull:
    permissions:
    contents: read
    packages: read
  3. Reference the image in container:. With the grant + permissions in place, no container.credentials block 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/deploy integration changes

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.

Cross-repo triggers

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).

Slack deploy notifications

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 1.52 hangs on Node 24

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 silently

Setting 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 browsers, size runner for parallelism

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.

X

Graph View