Skip to main content

Migrating between GitLab CI and GitHub Actions

catladder generates pipelines for both CI systems from the same catladder.ts, and both can be enabled at once. That is what makes this migration safe: run the new backend in parallel with the old one on the same commits, compare, and only then cut over.

pipelines: {
gitlab: true,
github: { gitRemote: "github" },
}

(pipelineType: "gitlab" is the deprecated single-backend form — replace it with pipelines as the first step.)

Never do this as one big-bang commit. Work through the steps below and stop at each decision point that is the user's to make — creating repositories, pushing to a new host, and moving secrets are all outward-facing actions that need their explicit go-ahead.

Step 0 — scope it with the user

Ask up front (once, with your reading of the repo attached):

  • Direction and end state: is the old backend going away, or do both stay live permanently (some projects keep GitLab for releases and GitHub for PR checks)?
  • Repository: does the target repo already exist, or must it be created and pushed?
  • Which backend owns releases during the parallel phase — see step 6.
  • Where secret values live afterwards — see step 5. This is the one that blocks decommissioning, so raise it early rather than at the end.

Step 1 — check the blockers before changing anything

Read the project's catladder.ts and report what will not carry over. Going GitLab → GitHub:

  • deploy: { type: "pages" } — not supported on the GitHub backend. Those components simply generate no deploy jobs there (a warning, not a generation error), so the site silently stops being published.
  • agents (the Claude agent integration) — GitLab-only; agent jobs are skipped on GitHub.
  • Default branch must be main — the generated GitHub main-branch workflow triggers on push: branches: [main]. A project on master or develop gets a workflow that never runs.
  • hooks.transformYamlBeforeWrite / transformFileBeforeWrite — hooks run for the GitHub files too. Any hook that keys on .gitlab-ci.yml or on GitLab's YAML shape needs revisiting; a hook that blindly rewrites data.include will corrupt workflow files.
  • customJobs do carry over (they are platform-neutral job specs), but anything GitLab-shaped inside them — raw rules:, only:, GitLab-specific job keys — does not.
  • secrets.vault: "gitlab" (the default) requires a GitLab project. See step 5.

Going GitHub → GitLab the feature list is easier (GitLab supports everything the GitHub backend does), but still check the hooks, and note that GitLab needs a real access token for releases (step 6).

Report this list to the user before touching the config. If a blocker has no first-class solution, that is worth an issue on catladder — https://git.panter.ch/catladder/catladder/-/issues.

Step 2 — get the repository onto the target host

For a new GitHub repo: create it and push main plus all tags (the release job derives the version from the nearest v* tag — a tagless mirror re-releases 1.0.0).

Add it as a second remote rather than replacing origin, so the old backend keeps working during the parallel phase:

git remote add github git@github.com:<owner>/<repo>.git
git push github main --tags

Ask before creating the repository or pushing — this publishes the code to a new host.

Step 3 — enable both backends

pipelines: {
gitlab: true,
github: { gitRemote: "github" },
}

gitRemote tells catladder which git remote points at that CI system's repository (default origin). Then regenerate and commit:

yarn catenv

The two file sets live side by side and never collide:

backendgenerated files
gitlab.gitlab-ci.yml, .catladder-generated/gitlab/
github.github/workflows/catladder-*.yml, .catladder-generated/github/scripts/

Shared (generated once for both): .catladder-generated/images/ and .catladder-generated/catci/.

If the hosted runners of the new backend are smaller than the old ones, tune them per backend instead of globally:

github: { gitRemote: "github", runnerVariables: { TURBO_CONCURRENCY: "2" } },

Step 4 — mirror the secrets to the new backend

GitHub Actions cannot read the vault at runtime, so the values must be pushed into GitHub:

gh auth login
yarn catladder project secrets-sync-github

This creates one GitHub environment per catladder env and writes each secret/variable into the environment(s) whose generated jobs actually reference it. (Environments are not cosmetic: GitHub caps a repository at 100 secrets, which a multi-env project blows through; the cap applies per environment.) Re-run it after every secret change for as long as both backends are live.

Then verify:

yarn catladder project doctor

It compares the secret/variable names the generated workflows reference against what is actually set, per environment, and prints the command that heals each gap. Values cannot be compared — GitHub secrets are write-only by design.

Step 5 — the secrets vault is what actually blocks decommissioning

secrets.vault defaults to "gitlab": the GitLab project variables double as the readable source of truth, and the CI backends only ever get mirrored copies. GitHub secrets can never serve as that source.

So if GitLab is going away, move the vault before deleting anything:

secrets: {
vault: { type: "bitwarden", collection: "catladder" },
}

A safe order:

  1. yarn catladder project secrets-pull <env>: --out /tmp/secrets.yml against the old vault (unset values show as 🚨 FILL ME)
  2. switch secrets.vault in catladder.ts, yarn catenv
  3. yarn catladder project secrets-push <env>: --file /tmp/secrets.yml
  4. yarn catladder project doctor, then delete the temp file
  5. only now decommission the GitLab project

Never commit the intermediate file, and see the catladder-secrets skill for the exact command surface.

Step 6 — releases and tokens

  • GitLab needs a GL_TOKEN project access token (named semantic-release) to push the release commit and tag. It is created and rotated by yarn catladder project setup.
  • GitHub needs nothing extra — the release jobs use the built-in github.token. Tags pushed by that token do not retrigger workflows, so the release job dispatches the tagged-release workflow explicitly; that is built in and needs no configuration.
  • Only one backend should own releases at a time. Both backends generate release jobs, and with releases: { when: "auto" } both would try to tag the same commit. During the parallel phase, agree with the user which backend releases; the other one's release job must stay unclicked (when: "manual") or the parallel phase must be short.

See the catladder-releases skill for how the release jobs differ (GitLab manual job vs. GitHub dispatch workflow).

Step 7 — registry and the first run

The job images (the catladder runner images) always live in the CI system's own registry, so they change host with the backend: $CI_REGISTRY_IMAGE on GitLab, ghcr.io/<owner>/<repo> on GitHub. The first pipeline on the new backend therefore rebuilds every job image — it will be slow, and that is expected, not a bug.

Where the app image goes depends on the deploy type:

  • google-cloudrun — the image goes to Google Artifact Registry (<region>-docker.pkg.dev/<projectId>/catladder-deploy/…) on both backends. Cloud Run can only pull from AR/GCR, so a backend migration does not move the app image at all. Nothing to do here.
  • kubernetes — the app image lives in the CI registry and does move. catladder handles the pull itself: every deploy runs kubernetesCreateSecret, which writes a docker-registry secret (gitlab-registry-<component>) into the namespace, and the chart wires it in as imagePullSecrets. Note the secret keeps its historical name on both backends.

The credential behind that secret differs per backend, and this is the one thing to raise before a kubernetes component moves to GitHub:

  • GitLab provisions a durable one automatically — catladder project setup creates a gitlab-deploy-token (scope read_registry), GitLab exposes it to jobs as CI_DEPLOY_USER / CI_DEPLOY_PASSWORD, and project doctor fails when it is missing.
  • GitHub has no equivalent. The deploy falls back to the workflow token, which expires with the run: the initial pull succeeds and the job goes green, but a pod rescheduled later cannot re-pull — ImagePullBackOff weeks after a deploy that looked fine. Until that is solved (catladder issue #75), a project moving kubernetes to GitHub must supply a long-lived registry credential itself, declared as secrets named CI_DEPLOY_USER / CI_DEPLOY_PASSWORD (a PAT with read:packages), which take precedence in the deploy.

Step 8 — run both, compare, then cut over

Let both pipelines run on the same commits until the new backend is green for all three triggers: a merge/pull request, the main branch, and a tagged release. Only then flip:

pipelines: { github: true }
yarn catenv

⚠️ catladder only cleans up backends that are still enabled — the disabled backend's files stay behind and must be deleted by hand:

  • gitlab: .gitlab-ci.yml and .catladder-generated/gitlab/
  • github: .github/workflows/catladder-*.yml and .catladder-generated/github/scripts/

Then, if the new host becomes origin, update the remotes and drop the now-wrong gitRemote, regenerate once more, and finish with yarn catladder project doctor.

After the cut-over

  • Set up merge gating — GitHub has none by default. On GitLab, "merge when pipeline succeeds" is built in; on a fresh GitHub repo a PR is mergeable the moment it opens, checks still running, and the auto-merge button does not even appear. yarn catladder project setup configures it: auto-merge + delete-merged-branches on the repo, and a repository ruleset (catladder merge gating) on the default branch requiring the generated catladder ✅ aggregate check (the one stable context — never require individual job names, they change with every component add/rename and a stale required name blocks its own PR forever). The ruleset carries a deploy-key bypass: the release job pushes the release commit and tag straight to the default branch, which the required check would otherwise reject (GH006) — and github deliberately never lets the workflow token bypass rulesets, so setup provisions a write deploy key ("catladder release", private half in the CATLADDER_RELEASE_KEY actions secret; deploy keys never expire) that the release job pushes with over ssh. Classic branch protection has no bypass list at all, which is why setup uses a ruleset and migrates old classic-protection gating away. project doctor verifies all of it. Required reviews stay a human choice.
  • Tell the team where the pipeline lives now and how manual actions work there (on GitHub, manual jobs are dispatch workflows in the Actions sidebar — 🚀 catladder create release, ▶️ catladder deploy, ⏹️ catladder stop, ↩️ catladder rollback).
  • Revoke tokens that are no longer needed (GL_TOKEN).
  • Keep the old host readable until the vault migration is verified in practice — a rotated secret is much cheaper to recover than a deleted one.
  • catladder-configcatladder.ts structure, pipelines, regeneration
  • catladder-secrets — vault, secrets-sync-github, secret commands
  • catladder-releases — release jobs and how they differ per backend
  • catladder-pipelines — triggers, generated file layout, debugging CI
  • catladder-cliproject doctor, project setup, non-interactive usage
  • catladder-migrate-package-manager — migrating between yarn and pnpm