Skip to content
Latchkey LogoLatchkey home

Migrate CircleCI to GitHub Actions: orbs, executors, contexts and test splitting

To migrate CircleCI to GitHub Actions, split each entry under workflows: into its own workflow file, turn requires: into needs:, replace orbs with actions, executors and resource_class with a runs-on label, contexts with environment or organization secrets, and persist_to_workspace with the artifact actions. The two things with no direct equivalent are timing-based test splitting and Docker Layer Caching, so plan those before the cutover.

CircleCI and GitHub Actions both keep YAML in the repository with jobs made of steps, so the job bodies translate quickly. The structure around them does not: one config.yml holds every workflow, orbs package jobs and commands you never see, and parallelism with circleci tests run gives you timing-balanced shards from one line of config.

This page maps each construct, converts a config that uses an orb, an executor, a context and four-way test splitting, and gives a resource_class to runner size table so the converted jobs keep the CPU and memory they had.

CircleCI config keys mapped to GitHub Actions keys, each marked Maps, Changes or Rewrite
Seventeen CircleCI constructs and where each lands. Test splitting, orbs and Docker Layer Caching are the rows without a mechanical path.

The concept map

GitHub's guide covers jobs, caching, workspaces and services. The orbs, contexts, resource classes and test splitting rows are where most of the migration time goes.

CircleCIGitHub ActionsWhat changes
workflows: (several in one file)One file per workflow in .github/workflows/Triggers move into each file's on:
requires:needs:None
filters: branches:on: push: branches: or if:None
orbs:Actions from the MarketplaceAn orb job or command has to be found or rewritten
commands:Composite actionLives in its own action.yml
executors: with docker:runs-on:, optionally container:CircleCI images set USER circleci, which conflicts with Actions file permissions
Second docker: imageservices:Named service with its own env:
resource_class:A runs-on: labelThe label is the size
context:Environment secrets or organization secretsAccess rules move from security groups to environments and repository access
parallelism: with circleci tests runstrategy: matrix: with your own splitNo built-in timing-based splitting
restore_cache / save_cacheactions/cache with key and restore-keysImmutable keys in both; {{ checksum }} becomes hashFiles()
persist_to_workspace / attach_workspaceupload-artifact / download-artifactNamed artifacts instead of one workspace
store_test_resultsNo built-in equivalentUpload JUnit XML as an artifact or use an action
Docker Layer CachingNo equivalentPick a build cache separately
type: approvalA job on an environment with required reviewersApproval is a property of the environment

Before: a .circleci/config.yml

One workflow, three jobs. The node orb installs packages, a reusable executor sets the image and size, test splits files across four containers by historical timing, and deploy reads a context and only runs on main.

.circleci/config.yml
version: 2.1

orbs:
  node: circleci/node@7.2.1

executors:
  app:
    docker:
      - image: cimg/node:22.11
    resource_class: large

jobs:
  build:
    executor: app
    steps:
      - checkout
      - node/install-packages
      - run: npm run build
      - persist_to_workspace:
          root: .
          paths: [dist]
  test:
    executor: app
    parallelism: 4
    environment:
      JEST_JUNIT_OUTPUT_DIR: reports
    steps:
      - checkout
      - node/install-packages
      - run: |
          npx jest --listTests | circleci tests run \
            --command "xargs npx jest --ci --reporters=default --reporters=jest-junit" \
            --split-by=timings
      - store_test_results:
          path: reports
  deploy:
    executor: app
    steps:
      - checkout
      - attach_workspace:
          at: .
      - run: ./scripts/deploy.sh

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

After: the converted workflow

The orb's install-packages command became setup-node with its npm cache plus npm ci. GitHub recommends moving off CircleCI's prebuilt images, so the jobs run on the runner directly instead of in cimg/node. The context became the production environment, whose secrets only the deploy job can read.

The four test containers became a four-cell matrix using Jest's own --shard flag. That splits by test file, not by recorded timing, so shards will be less even than CircleCI's. To get timing balance back, upload each shard's JUnit XML as an artifact and feed past durations into a splitter, as sharding tests across GitHub Actions runners describes.

.github/workflows/build-test-deploy.yml
name: build-test-deploy
on:
  push:
  pull_request:
jobs:
  build:
    runs-on: ubuntu-latest           # was: resource_class: large
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: dist
          path: dist
  test:
    needs: build
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]             # was: parallelism: 4
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npx jest --ci --shard=${{ matrix.shard }}/4
  deploy:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production             # was: context: production-deploy
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: dist
          path: dist
      - run: ./scripts/deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Resource classes and runner sizes

A resource_class is a size, and on Actions the size is whatever the runs-on label resolves to. Match on vCPU first, then check memory: CircleCI's Docker classes give 2 GB per vCPU, so a job that fit there fits in anything with more. Moving down a size to save money is a separate decision from the migration; make it after the timings are known.

If a job never set resource_class, CircleCI's reference says it ran on a default that can change, so check which class its recent runs actually used before you pick a label.

CircleCI Docker classvCPU and RAMLatchkey label at the same vCPU
medium2 vCPU, 4 GBlatchkey-small: 2 vCPU, 8 GB
large4 vCPU, 8 GBlatchkey-medium: 4 vCPU, 16 GB
xlarge8 vCPU, 16 GBlatchkey-large: 8 vCPU, 32 GB
2xlarge16 vCPU, 32 GBlatchkey-xlarge: 16 vCPU, 64 GB

Orbs, test timing and layer caching: the three to plan

Orbs. An orb call is one line in your config and dozens in the orb, so you cannot replace it until you know what it ran. The CircleCI CLI prints the YAML source of any orb version. Read the commands you call, note every step that installs a tool or writes a cache, and find the action or the two lines of shell that do the same. For circleci/node, install-packages is a lockfile install with a cache around it, which setup-node with cache: npm already covers. Orbs that wrap a vendor CLI usually have an official action from the same vendor; check the Marketplace before rewriting one.

Test timing. circleci tests run --split-by=timings balances shards from the JUnit results CircleCI stored for earlier runs. Actions stores nothing between runs unless you do, so the converted workflow starts with file-based shards. Keep the JUnit reporter, and when shard times drift apart, add the timing loop described in parallelizing tests by timing.

Layer caching. CircleCI's Docker Layer Caching was a checkbox on the job. GitHub states there is no equivalent, so a Docker build that relied on it will rebuild every layer on the first runs. Docker layer caching in GitHub Actions compares the BuildKit cache backends you can use instead.

Terminal
circleci orb source circleci/node@7.2.1 > node-orb.yml
grep -n -A12 'install-packages:' node-orb.yml

The migration checklist

Work through it per workflow in config.yml, plus every orb it imports.

  • Runner labels. Every executor and resource_class becomes a runs-on label from the table above. Self-hosted CircleCI runner classes become self-hosted runners; the Importer marks those executors unsupported.
  • Secrets. Project environment variables and contexts are both manual tasks in GitHub Actions Importer. Recreate context values as environment secrets where approval mattered, organization secrets where it did not.
  • Cache keys. {{ checksum "package-lock.json" }} becomes hashFiles('package-lock.json'). A list of fallback keys becomes key plus restore-keys. Both systems refuse to overwrite an existing key, so the versioning habit (v1-) carries over.
  • Matrix. parallelism becomes a matrix over a shard index; CircleCI matrix parameters become strategy.matrix.
  • Artifacts. persist_to_workspace and store_artifacts both become upload-artifact; give each a name, because there is no single workspace.
  • Concurrency. A serial-group becomes a concurrency: group. Add queue: max so every waiting deploy keeps its place; without it, GitHub keeps one pending run and cancels the one already waiting when a newer one arrives.

Key takeaways

  • One config.yml becomes one workflow file per entry under workflows:.
  • Orbs and contexts are manual work in GitHub Actions Importer; budget time for them.
  • Timing-based test splitting is not built into Actions: a matrix with --shard splits by file, not by duration.
  • Map resource_class to a runner label by vCPU, then confirm memory.

Frequently asked questions

What is the GitHub Actions equivalent of CircleCI orbs?
Actions. An orb bundles jobs, commands and executors, and Actions splits those apart: commands become actions, usually from the Marketplace or a composite action you write, and jobs become reusable workflows. GitHub Actions Importer converts known orbs and leaves unknown orbs as manual tasks.
How do I split tests across parallel jobs in GitHub Actions?
Use a matrix over a shard index and give each job its slice, either with the test runner's own option such as Jest's --shard=1/4 or with a script. GitHub states that CircleCI-style timing-based splitting is not built in, so balancing by duration means storing past timings yourself.
What replaces CircleCI contexts in GitHub Actions?
Organization secrets when the point was sharing values across repositories, and environment secrets when the point was restricting who can use them. An environment can require reviewers and restrict which branches deploy, which covers what CircleCI security groups on a context did.
Can I keep using the cimg Docker images on GitHub Actions?
You can run them with container:, but GitHub advises against it: the images set USER circleci, which conflicts with the runner's file permissions on the workspace. GitHub's own converted example needs a chmod step to work around it. Use the runner image and setup actions instead.

References

resource_class: large becomes latchkey-medium, 4 vCPU and 16 GB on Latchkey. Start free → 30-day trial · No credit card