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

> Migrate CircleCI to GitHub Actions: orbs, executors, contexts, resource classes and test splitting mapped, with a config.yml converted line by line.

Source: https://latchkey.dev/learn/migrate/circleci-to-github-actions  
Updated: 2026-11-12

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.

## 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.

| CircleCI | GitHub Actions | What 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 Marketplace | An orb job or command has to be found or rewritten |
| `commands:` | Composite action | Lives 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:` image | `services:` | Named service with its own `env:` |
| `resource_class:` | A `runs-on:` label | The label is the size |
| `context:` | Environment secrets or organization secrets | Access rules move from security groups to environments and repository access |
| `parallelism:` with `circleci tests run` | `strategy: matrix:` with your own split | No built-in timing-based splitting |
| `restore_cache` / `save_cache` | `actions/cache` with `key` and `restore-keys` | Immutable keys in both; `{{ checksum }}` becomes `hashFiles()` |
| `persist_to_workspace` / `attach_workspace` | `upload-artifact` / `download-artifact` | Named artifacts instead of one workspace |
| `store_test_results` | No built-in equivalent | Upload JUnit XML as an artifact or use an action |
| Docker Layer Caching | No equivalent | Pick a build cache separately |
| `type: approval` | A job on an environment with required reviewers | Approval is a property of the environment |

> From docs.github.com "Migrating from CircleCI to GitHub Actions" and the CircleCI Importer guide, and circleci.com/docs Configuration reference, Using contexts, Caching and Test splitting, read 2026-11-12. GitHub's guide states that timing-based test parallelism "is not built into GitHub Actions" and that Actions has no equivalent of Docker Layer Caching.

## 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](/learn/speed/shard-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 class | vCPU and RAM | Latchkey label at the same vCPU |
| --- | --- | --- |
| `medium` | 2 vCPU, 4 GB | `latchkey-small`: 2 vCPU, 8 GB |
| `large` | 4 vCPU, 8 GB | `latchkey-medium`: 4 vCPU, 16 GB |
| `xlarge` | 8 vCPU, 16 GB | `latchkey-large`: 8 vCPU, 32 GB |
| `2xlarge` | 16 vCPU, 32 GB | `latchkey-xlarge`: 16 vCPU, 64 GB |

> CircleCI x86 Docker resource classes from the CircleCI Configuration reference, read 2026-11-12. Latchkey sizes from latchkey.dev/documentation/runners-overview, read 2026-11-12. CircleCI also lists `small`, `medium+` and `2xlarge+`, which have no same-vCPU label here.

## 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](/learn/speed/parallelize-tests-by-timing-in-ci).

**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](/learn/speed/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.

> CircleCI serial groups queue later jobs first in, first out (CircleCI Docs, controlling serial execution across your organization). GitHub's pending-run rule and `queue: max` (up to 100 pending) are from [Control the concurrency of workflows and jobs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). Both read 2026-11-12.

## FAQ

### 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

- [GitHub Docs: Migrating from CircleCI to GitHub Actions (read 2026-11-12)](https://docs.github.com/en/actions/tutorials/migrate-to-github-actions/manual-migrations/migrate-from-circleci)
- [GitHub Docs: Migrating from CircleCI with GitHub Actions Importer, manual tasks and supported syntax](https://docs.github.com/en/actions/tutorials/migrate-to-github-actions/automated-migrations/circleci-migration)
- [CircleCI Docs: Configuration reference, resource classes and executors](https://circleci.com/docs/reference/configuration-reference/)
- [CircleCI Docs: Test splitting and parallelism](https://circleci.com/docs/guides/optimize/parallelism-faster-jobs/)
- [Jest CLI options: --shard](https://jestjs.io/docs/cli#--shard)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
