Can I Mix GitHub-Hosted and Managed Runners?

Yes. runs-on is evaluated per job, so one workflow can send heavy jobs to a warp- label and leave short jobs on GitHub-hosted runners with no other change.

Last verified:

Answer

Yes. runs-on is evaluated per job, so a single workflow file can send one job to a GitHub-hosted runner and the next job to a self-hosted or managed pool. Nothing at the workflow, repository, or organization level forces every job in a run onto one runner type.

A mixed fleet is a normal end state rather than a stage you have to pass through. A two minute lint job stays on ubuntu-latest, while the test and build jobs that own most of the wall clock move to a larger label. Here is the smallest form, two jobs in one file:

name: ci
on:
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run lint

  integration:
    runs-on: warp-ubuntu-latest-x64-16x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run test:integration

Managed runners are self-hosted runners from GitHub's point of view. WarpBuild registers ephemeral virtual machines against your GitHub organization in a runner group, starting with the Default group at id 1, and GitHub schedules a job onto one of them when the job's label matches. The labels and per minute rates are in the cloud runners documentation.

Detail

How runs-on resolves, one job at a time

runs-on accepts three shapes, and the shape changes the matching rule.

A plain string matches that one label. runs-on: warp-ubuntu-latest-x64-8x asks for a runner advertising exactly that label, which is what every WarpBuild label is designed for.

An array matches a runner that carries every label in the array. runs-on: [self-hosted, linux, x64] needs all three labels present on the same machine, so an array is an AND rather than a list of alternatives. Wrapping a warp- label in an array adds nothing and can fail to match if the other entries are not also advertised.

An object with a group key restricts the job to a runner group, optionally with labels: runs-on: { group: build-fleet, labels: warp-ubuntu-latest-x64-8x }. That form matters when your organization runs more than one group and you want the job pinned to one of them.

WarpBuild also accepts dynamic labels appended after a semicolon, as in warp-ubuntu-latest-x64-4x;nested-virtualization.enabled=true for jobs that need /dev/kvm. The label stays one string, so the string form of runs-on still applies.

An expression is legal anywhere a string is, which is how conditional routing works:

jobs:
  test:
    runs-on: ${{ github.event_name == 'pull_request' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }}

Pull request runs land on the 8 vCPU label where developers are waiting; scheduled and push runs stay on the GitHub-hosted default.

A matrix that moves the heavy jobs and leaves the short ones alone

The routing decision belongs in one place rather than scattered across job definitions. A matrix with include gives you a table of job name, runner label, and command, and the label selection expression is a single runs-on line reading from it.

name: checks
on:
  pull_request:

jobs:
  checks:
    name: ${{ matrix.name }}
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: lint
            runner: ubuntu-latest
            command: npm run lint
          - name: typecheck
            runner: ubuntu-latest
            command: npm run typecheck
          - name: unit
            runner: warp-ubuntu-latest-x64-8x
            command: npm run test:unit
          - name: integration
            runner: warp-ubuntu-latest-x64-16x
            command: npm run test:integration
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: ${{ matrix.command }}

Four jobs, two runner pools, one file. Adding a fifth row or changing unit from an 8 vCPU label to a 16 vCPU label is a one line edit, and fail-fast: false keeps the other legs running while you are still tuning the split. The guide to GitHub Actions matrix builds covers the rest of the matrix syntax, including how include interacts with generated axes.

A rule of thumb for which row goes where: jobs bounded by network or by a single core, such as lint, typecheck, and changelog checks, gain little from more vCPU. Jobs that saturate every core, such as compilation, test suites that shard by worker, and image builds, are the ones worth routing.

The migration pattern: move one job, measure, then move the rest

Mixing is what makes an evidence-driven migration possible, because the old configuration keeps running next to the new one on the same commits.

  1. Pick the job with the highest total billed minutes, which is usually the longest job multiplied by the number of runs rather than the single slowest job.
  2. Change only that job's runs-on to a warp- label at the same vCPU count you are on today. Same size first keeps the comparison clean.
  3. Let it run for a week so the sample covers a normal mix of pull requests, merges, and reruns.
  4. Open the Reports page and read two tables. The Jobs table aggregates per repository, workflow, and job name, giving run count, success rate, duration P75 and P90, and queue time P75 and P90. The CI billing table has one row per job execution with repository, job name, runner label, execution time, billed time, and cost. Filter both by runner label to separate the moved job from everything still on the old pool.
  5. Export the CSV, compare duration P75 and cost per run against the same job before the change, then move the next job.

Queue time is the number teams forget. A job whose execution time drops while its queue time rises has moved its wait rather than removed it, and the Queue Timings section breaks P75 and P90 queue time out per runner label so you can see that directly.

Keep step 2 honest by changing one variable at a time. Moving the job and raising its vCPU count in the same commit leaves you unable to attribute the result to either change.

What the split costs

Every WarpBuild rate below comes from the cloud runners documentation. Every GitHub rate comes from the GitHub Actions billing reference and the GitHub-hosted runner specifications, checked on 2026-08-13.

WarpBuild labelShapeWarpBuild per minuteGitHub-hosted equivalentGitHub per minuteList price difference
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004ubuntu-latest, 2 vCPU, 8 GB on private repositories$0.00633 percent lower list price
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.0084-core Linux larger runner$0.01233 percent lower list price
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.0168-core Linux larger runner$0.02227 percent lower list price
warp-ubuntu-latest-x64-16x16 vCPU, 64 GB$0.03216-core Linux larger runner$0.04224 percent lower list price
warp-ubuntu-latest-x64-32x32 vCPU, 128 GB$0.06432-core Linux larger runner$0.08222 percent lower list price

The 2 vCPU row uses GitHub's private repository rate, which is the rate paying teams run against.

Worked model: one repository, three job types, 800 runs a month. The model holds minutes constant so that list price is the only variable being compared.

JobMinutes per runMonthly minutesRunner in the mixed fleetMixed fleet costSame job on the GitHub-hosted equivalent
lint21,600ubuntu-latest$9.60$9.60
unit tests97,200warp-ubuntu-latest-x64-8x$115.20$158.40
integration64,800warp-ubuntu-latest-x64-16x$153.60$201.60
Total13,600$278.40$369.60

The two heavy jobs account for the whole $91.20 monthly difference, or $1,094.40 over twelve months, at identical vCPU shapes and identical minute counts. Moving lint as well would take 1,600 minutes from $0.006 to $0.004 on warp-ubuntu-latest-x64-2x, worth $3.20 a month, which is why it is the last job to touch rather than the first.

Ten dollars buys 2,500 minutes on warp-ubuntu-latest-x64-2x at $0.004 per minute or 625 minutes on warp-ubuntu-latest-x64-8x at $0.016 per minute, which is enough to run one job on the new pool for a week before any invoice exists. Rates for macOS and Windows labels are on the pricing page.

Where a mixed fleet needs attention

Four differences account for most of the friction, and all four are workable once you know about them.

Cache actions are per pool. actions/cache writes to GitHub cache storage from either pool. WarpBuilds/cache is a drop-in replacement that writes to WarpBuild cache storage and is enabled by default on Linux warp- labels. A job that switches between the two starts cold once, so switch the cache action in the same commit as the label and expect the first run after the change to be slower than steady state.

Images differ in details. The Linux x86-64 runner images carry the same tooling as GitHub-hosted runners, so most steps port unchanged. The Ubuntu 24.04 ARM64 images set the work directory to /runner/_work rather than GitHub's /home/runner/work/, so a step with a hardcoded path breaks on that pool. Use ${{ github.workspace }} in YAML and $GITHUB_WORKSPACE in shell steps.

Public repositories need one setting. GitHub disables self-hosted runners in public repositories by default, which covers managed runners too. The toggle lives in the organization's runner group settings, and the cloud runners documentation links the exact page.

Runner storage is ephemeral on both sides. Nothing survives the job on either pool, so anything a downstream job needs goes to an artifact, a cache, or a registry. That is what keeps a job on ubuntu-latest and a job on warp-ubuntu-latest-x64-16x able to hand work to each other inside one run.

A mixed fleet can therefore adopt them job by job on the same schedule as the labels.

Do I need a separate runner group to mix runner pools?

No. GitHub-hosted runners are not in any runner group, and managed runners register into a group you choose, with the Default group at id 1 as the starting point. A group only controls which repositories and workflows may reach the self-hosted side, so a mixed workflow works with the Default group untouched. The guide to GitHub Actions runner groups covers when a second group earns its keep, and GitHub's workflow restrictions on a group are the reason a job that matches a label can still fail to pick up a runner.

How does GitHub match a job to the right runner?

A string in runs-on matches that one label. An array matches a runner carrying every label in the array, so [self-hosted, linux, x64] needs all three on the same runner. A warp- label is a single string, so keep it as a plain string and let the label do the routing. Changing a job from one pool to the other is that one line, as covered in how to change the runner in a GitHub Actions workflow.

Can I move one job at a time instead of the whole workflow?

Yes, and that is the pattern that produces evidence. Change runs-on for one job, let it run for a week, then compare that job against its old rows in the Reports Jobs table for duration P75 and P90 and in the CI billing table for cost per run before moving the next job. Both tables live on the Reports page and export to CSV, so the comparison ends up in a spreadsheet rather than in an argument.

Do caches and artifacts carry across the two runner pools?

Artifacts do, because actions/upload-artifact and actions/download-artifact write to GitHub storage from any runner. Caches depend on the action. actions/cache uses GitHub cache storage on both pools, while WarpBuilds/cache uses WarpBuild cache storage and is available on warp- Linux labels, so a job that switches cache actions starts from a cold cache once. The background on the two runner types is in the difference between GitHub-hosted and self-hosted runners.

Start by routing the single job with the highest billed minutes to a matching warp- label from the cloud runners documentation, price the change against your own minute counts on the pricing page, and read the result out of the Reports page before moving the next job.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.