Skip to content
compiler.dev

Migrate CircleCI to GitHub Actions: Step by Step

Last updated

Migrating from CircleCI to GitHub Actions is mostly a translation job: jobs stay jobs, workflows become needs, orbs become marketplace actions, and save_cache becomes actions/cache. A small pipeline takes an afternoon. The hard parts are secrets, contexts, and anything that relied on CircleCI-specific features such as resource_class or test splitting. This guide covers the mapping, a real translation, the automated Importer, and a cutover plan that avoids downtime.

For the product-level comparison, see CircleCI vs GitHub Actions and CircleCI alternatives.

Concept mapping

CircleCIGitHub Actions
.circleci/config.yml.github/workflows/*.yml (one file per workflow)
workflows: with requiresjobs.<id>.needs
jobs:jobs:
docker: / machine: executorruns-on: plus container: if you want a job image
Secondary docker: imagesservices:
resource_classRunner label, for example a larger runner or a self-hosted label
steps: - runsteps: - run
checkoutactions/checkout
save_cache / restore_cacheactions/cache, or cache: in setup-* actions
persist_to_workspace / attach_workspaceactions/upload-artifact / actions/download-artifact
store_artifactsactions/upload-artifact
store_test_resultsA test reporter action, or upload-artifact of the JUnit files
parallelism: N + test splittingstrategy.matrix; see test sharding
OrbsMarketplace actions, composite actions, reusable workflows
ContextsEnvironments, organization secrets and variables
Project environment variablesRepository secrets and variables
filters: brancheson.push.branches / if: conditions
Scheduled workflows (triggers: schedule)on.schedule; see cron schedules
when: / unless:if: expressions

A realistic pipeline, side by side

CircleCI, a Node app with Postgres, build and test, then deploy from main:

# .circleci/config.yml
version: 2.1
orbs:
  node: circleci/node@5

jobs:
  test:
    docker:
      - image: cimg/node:22.11
      - image: cimg/postgres:16.4
        environment:
          POSTGRES_PASSWORD: postgres
    resource_class: large
    parallelism: 4
    steps:
      - checkout
      - restore_cache:
          keys:
            - npm-v1-{{ checksum "package-lock.json" }}
            - npm-v1-
      - run: npm ci
      - save_cache:
          key: npm-v1-{{ checksum "package-lock.json" }}
          paths: [~/.npm]
      - run:
          command: |
            TESTS=$(circleci tests glob "test/**/*.test.js" | circleci tests split --split-by=timings)
            npm test -- $TESTS
          environment:
            DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
      - store_test_results:
          path: reports
  deploy:
    docker:
      - image: cimg/node:22.11
    steps:
      - checkout
      - run: ./scripts/deploy.sh

workflows:
  ci:
    jobs:
      - test
      - deploy:
          requires: [test]
          context: production
          filters:
            branches:
              only: main

GitHub Actions equivalent:

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest        # or [self-hosted, Linux, compiler-ci]
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    services:
      postgres:
        image: postgres:16.4
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/4 --ci --reporters=default --reporters=jest-junit
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results-${{ matrix.shard }}
          path: reports

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v6
      - run: ./scripts/deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

What changed, and why:

  • The primary Docker image becomes the runner itself. Use setup-node instead of cimg/node, or keep an image with container:.
  • Secondary images become services:. The service is reachable on localhost with the port mapping when the job runs on the runner directly, and by service name when the job itself uses container:. Add a health check so tests do not start before Postgres is ready.
  • parallelism: 4 plus circleci tests split becomes a matrix. CircleCI splits by recorded timings; Jest's built-in --shard splits by file order, which is not timing-aware. If timing balance matters, see test sharding.
  • The context production becomes an environment: production, which can also require approvals and scope secrets.

Secrets and environment variables

CircleCI project variables become repository secrets. Contexts shared across projects become organization secrets (restrict by repository) or environment secrets. You cannot read secret values back from CircleCI's UI, so export them from wherever you originally stored them; plan for that early. Non-sensitive values go in repository or organization variables, referenced as ${{ vars.NAME }}.

Prefer short-lived credentials over stored keys for cloud access; see GitHub Actions OIDC with AWS and securing GitHub Actions. Secrets are not passed to workflows triggered from forks by default.

Caching and artifacts

restore_cache with a key list maps directly onto key plus restore-keys. Most setups simplify to cache: on setup-node, setup-python, setup-go and similar. See the per-ecosystem guides: npm, pip, Gradle, Go, and the general caching guide. The repository cache limit is 10 GB; see cache limits.

For persist_to_workspace, upload the files in one job and download them in the next:

- uses: actions/upload-artifact@v4
  with: { name: build, path: dist }
# in the next job
- uses: actions/download-artifact@v4
  with: { name: build }

Artifacts are stored per run, with a retention period you can set.

Automated conversion: GitHub Actions Importer

GitHub provides an Importer as a gh extension that supports CircleCI, Jenkins, GitLab CI, Azure DevOps, Bamboo, Bitbucket Pipelines and Travis CI (per GitHub's docs, checked October 2026). It needs Docker and the GitHub CLI.

gh extension install github/gh-actions-importer
gh actions-importer configure     # sets up tokens for GitHub and CircleCI
gh actions-importer audit circle-ci --output-dir audit
gh actions-importer dry-run circle-ci --output-dir out ...

The documented commands are audit (analyse your pipelines), forecast (estimate Actions usage from past runs), dry-run (write converted workflows locally) and migrate (open a pull request). Run audit first: it tells you which constructs convert and which need manual work, which makes the project plan realistic. Treat output as a first draft. Orbs and custom commands often need hand conversion, and the converted YAML is verbose. Check the current Importer docs for exact flags for your version.

Cutover plan

  1. Audit with the Importer and list orbs, contexts, and scheduled pipelines.
  2. Port one workflow (usually the main test job) and run it on a branch.
  3. Run both in parallel for a week or two. Compare duration and pass rate; fix flaky differences.
  4. Move secrets and environments, then deployment jobs. Test deploys against a staging environment first.
  5. Update branch protection to require the GitHub checks instead of the CircleCI ones.
  6. Disable CircleCI triggers, keep the config in git history, and remove it after a release cycle.

After the move: cost and speed

Default GitHub-hosted runners are 2 vCPU. If your CircleCI jobs used large or xlarge resource classes, expect GitHub's default runners to feel slower. Compare GitHub's larger runners with alternatives in GitHub Actions pricing. compiler.dev runs the same workflow on 8 vCPU / 16 GB machines for $0.012 per minute by changing runs-on to [self-hosted, Linux, compiler-ci], with automatic fallback to your original runners if anything fails; see pricing.

FAQ

How long does a CircleCI to GitHub Actions migration take?

A single pipeline with standard orbs: hours. A dozen pipelines with custom orbs, contexts and deployment logic: weeks, mostly for validation and secrets.

What replaces CircleCI orbs?

Marketplace actions for common tools, composite actions for reusable step sequences, and reusable workflows for whole jobs.

Can I keep CircleCI test splitting by timings?

Not natively. Jest and Playwright shard by file or test count; for timing-based balancing, use a splitting script or a tool built for it.

Do I lose CircleCI's SSH debugging?

There is no built-in equivalent. Community actions open a temporary SSH or tmate session; evaluate their security before using them.

Can I run both for a while?

Yes, and you should. Run both until results match, then switch required checks.

Run this workflow on compiler.dev 200 free minutes

Start free