Migrating from GitHub Actions
gitlab-warden migrate translates .github/workflows/*.yml into GitLab CI
YAML. Under the hood runs the same transformer as chant migrate (the
engine lives in @intentius/chant-lexicon-gitlab), so a single workflow
produces the same output either way. Warden adds the batch layer a real
migration needs: point it at the whole workflows directory and it translates
every file, then stitches a root .gitlab-ci.yml that includes them all.
# one workflow → stdout (findings go to stderr, stdout stays pipeable)
gitlab-warden migrate .github/workflows/ci.yml > .gitlab-ci.yml
# the whole directory → per-workflow files + a stitched root .gitlab-ci.yml
gitlab-warden migrate .github/workflows/ -o .
It is a workflow translator, not a project migration: it reads workflow YAML and emits pipeline YAML. Repository imports, variables, and settings are the reconcile side of warden (POLICY.md).
Flags
| Flag | Default | Meaning |
|---|---|---|
<path> |
required | one workflow file, or a directory (typically .github/workflows/) |
-o, --output <path> |
stdout / ./ |
output file (single input) or directory (directory input) |
--emit yaml\|ts |
yaml |
emit provider-native YAML, or typed chant TypeScript |
--strict |
off | exit 1 when any finding reaches error severity (lossy findings escalate) |
--report <file> |
— | write the findings as SARIF v2.1.0 |
--use-composites |
off | rewrite recognized job shapes to chant composites (--emit ts) |
--stitch / --no-stitch |
on | directory mode: emit the root .gitlab-ci.yml of include: local: entries |
Exit codes follow the CLI convention: 0 success, 2 argument error, 3 runtime
error. Exit 1 means --strict found error-severity diagnostics, which is a
different failure than the reconcile guardrail block that also exits 1.
What translates cleanly, and what doesn't
The transformer is explicit about fidelity: every translation decision is recorded against one of 38 rules, and everything lossy becomes a warning with remediation text on stderr. The tables below condense those rules.
| Translates cleanly | Becomes |
|---|---|
on: push / pull_request / triggers |
workflow.rules on $CI_PIPELINE_SOURCE |
env (workflow, job, step) |
variables |
needs: |
needs: (kept as a DAG) |
strategy.matrix |
parallel.matrix |
timeout-minutes, continue-on-error |
timeout, allow_failure |
concurrency |
resource_group / interruptible |
services:, container.image |
services:, image |
${{ github.* }} expressions |
predefined $CI_* variables |
| 33 common marketplace actions | native GitLab equivalents |
| Lossy (warning + remediation) | Why |
|---|---|
on: schedule (MIG-ON-SCHEDULE) |
GitLab keeps cron out of YAML; see below |
workflow_dispatch inputs |
need spec:inputs (GitLab 17+) with defaults |
permissions: |
no per-job equivalent; configure token access in the project |
job outputs: |
need the artifacts:reports:dotenv pattern |
non-Linux runs-on |
needs a self-hosted runner with tags |
| reusable workflows | become include:project: without typed inputs |
| unmapped marketplace actions | ported by hand |
| pinned action SHAs, secret scoping | security posture re-established on the GitLab side |
The security-fate analysis always runs. A pinned action SHA that could not be carried, a secret that now assumes a masked and protected variable, an injection site translated verbatim: each surfaces as a finding, so nothing weakens silently in the move.
Worked example
Given .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 6 * * 1'
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: make build
Running gitlab-warden migrate .github/workflows/ci.yml puts this on
stdout.
stages:
- build
workflow:
name: CI
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PIPELINE_SOURCE == "schedule"'
build:
image: ubuntu:24.04
script:
- make build
stage: build
and reports on stderr that MIG-ON-SCHEDULE fired, ending with a
ready-to-paste policy block (below).
In directory mode each workflow becomes <name>.gitlab-ci.yml and the root
.gitlab-ci.yml stitches them together. The root also declares the union of
every workflow's stages: GitLab merges top-level keys across includes with
the later file winning, so without the union two pipelines with different
stage lists would clobber each other.
stages:
- build
- test
include:
- local: ci.gitlab-ci.yml
- local: nightly.gitlab-ci.yml
The end-to-end suite validates exactly this output against a real GitLab instance: the stitched set is committed to a project and checked with GitLab's own project-scoped CI lint endpoint.
Schedules, and where this is going
GitHub puts cron in the workflow file. GitLab puts it in the project's
CI/CD settings under Pipeline schedules, so on: schedule cannot be carried
into the YAML. The translated rules still gate on
$CI_PIPELINE_SOURCE == "schedule", which means the migrated jobs never run
until a pipeline schedule exists.
When migrate drops a schedule it prints the exact state you lost, shaped as policy:
pipelineSchedules:
- description: "migrated from ci.yml"
cron: "0 6 * * 1"
ref: main
Today that block documents the manual step precisely. Phase 2 of this work
is a pipeline-schedules reconcile cycle that consumes it: declare the
block on a project node and warden keeps the live schedules converged with
it, drift-corrected and ownership-gated like any other slice. Migration
produces the pipeline; warden governs the destination the diagnostics point
at.
Running it in CI
See the migrate recipe in CI.md for a pipeline job that translates workflows and uploads the SARIF report as an artifact.