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 onpush: branches: [main]. A project onmasterordevelopgets a workflow that never runs. hooks.transformYamlBeforeWrite/transformFileBeforeWrite— hooks run for the GitHub files too. Any hook that keys on.gitlab-ci.ymlor on GitLab's YAML shape needs revisiting; a hook that blindly rewritesdata.includewill corrupt workflow files.customJobsdo carry over (they are platform-neutral job specs), but anything GitLab-shaped inside them — rawrules:,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:
| backend | generated 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:
yarn catladder project secrets-pull <env>: --out /tmp/secrets.ymlagainst the old vault (unset values show as🚨 FILL ME)- switch
secrets.vaultincatladder.ts,yarn catenv yarn catladder project secrets-push <env>: --file /tmp/secrets.ymlyarn catladder project doctor, then delete the temp file- 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_TOKENproject access token (namedsemantic-release) to push the release commit and tag. It is created and rotated byyarn 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 runskubernetesCreateSecret, which writes adocker-registrysecret (gitlab-registry-<component>) into the namespace, and the chart wires it in asimagePullSecrets. 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 setupcreates agitlab-deploy-token(scoperead_registry), GitLab exposes it to jobs asCI_DEPLOY_USER/CI_DEPLOY_PASSWORD, andproject doctorfails 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 —
ImagePullBackOffweeks 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 namedCI_DEPLOY_USER/CI_DEPLOY_PASSWORD(a PAT withread: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.ymland.catladder-generated/gitlab/ - github:
.github/workflows/catladder-*.ymland.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 setupconfigures it: auto-merge + delete-merged-branches on the repo, and a repository ruleset (catladder merge gating) on the default branch requiring the generatedcatladder ✅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 theCATLADDER_RELEASE_KEYactions 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 doctorverifies 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.
Related skills
catladder-config—catladder.tsstructure,pipelines, regenerationcatladder-secrets— vault,secrets-sync-github, secret commandscatladder-releases— release jobs and how they differ per backendcatladder-pipelines— triggers, generated file layout, debugging CIcatladder-cli—project doctor,project setup, non-interactive usagecatladder-migrate-package-manager— migrating between yarn and pnpm